forked from eatvio/eatvio_app
231 lines
8.8 KiB
Dart
231 lines
8.8 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:eatvio_app/generated/l10n.dart';
|
|
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;
|
|
final int maxLines;
|
|
|
|
const ExpandableText({
|
|
super.key,
|
|
required this.text,
|
|
this.maxLines = 5, // по умолчанию 5 строк
|
|
});
|
|
|
|
@override
|
|
State<ExpandableText> createState() => _ExpandableTextState();
|
|
}
|
|
|
|
class _ExpandableTextState extends State<ExpandableText> {
|
|
bool isExpanded = false;
|
|
bool showExpandButton = false;
|
|
|
|
final GlobalKey _textKey = GlobalKey();
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _checkTextOverflow());
|
|
}
|
|
|
|
void _checkTextOverflow() {
|
|
final textRenderObject = _textKey.currentContext?.findRenderObject();
|
|
if (textRenderObject is RenderBox) {
|
|
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) {
|
|
setState(() {
|
|
showExpandButton = true;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void toggleExpansion() {
|
|
setState(() {
|
|
isExpanded = !isExpanded;
|
|
});
|
|
}
|
|
|
|
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: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: AnimatedSize(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeInOut,
|
|
child: Stack(
|
|
children: [
|
|
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(
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Container(
|
|
height: 35,
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.white.withAlpha(0),
|
|
Colors.white.withAlpha((255 * 0.6).toInt()),
|
|
Colors.white,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (showExpandButton)
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: GestureDetector(
|
|
onTap: toggleExpansion,
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
isExpanded ? s.hide : s.expand,
|
|
style: TextStyles.textMMedium.copyWith(color: appTheme.blueA400),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Transform.rotate(
|
|
angle: isExpanded ? math.pi : 0,
|
|
child: SvgPicture.asset('assets/icons/arrow_down.svg', width: 12, height: 12),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LinkRange {
|
|
final int start;
|
|
final int end;
|
|
_LinkRange(this.start, this.end);
|
|
}
|