import 'package:flutter/material.dart'; class MarkdownReadMoreText extends StatefulWidget { final String text; final int trimLines; final TextStyle style; final TextStyle linkStyle; final String expandText; final String collapseText; const MarkdownReadMoreText( this.text, { super.key, this.trimLines = 3, required this.style, required this.linkStyle, this.expandText = 'Развернуть', this.collapseText = 'Свернуть', }); @override State createState() => _MarkdownReadMoreTextState(); } class _MarkdownReadMoreTextState extends State { bool _isExpanded = false; @override Widget build(BuildContext context) { final spans = _parseMarkdown(widget.text); final fullText = TextSpan(children: spans, style: widget.style); return LayoutBuilder( builder: (context, constraints) { final textPainter = TextPainter( text: fullText, maxLines: widget.trimLines, textDirection: TextDirection.ltr, )..layout(maxWidth: constraints.maxWidth); final isOverflowed = textPainter.didExceedMaxLines; if (!isOverflowed) { return SelectableText.rich(fullText); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText.rich( _isExpanded ? fullText : _getTrimmedSpan(textPainter, spans), ), GestureDetector( onTap: () => setState(() => _isExpanded = !_isExpanded), child: Text( _isExpanded ? widget.collapseText : widget.expandText, style: widget.linkStyle, ), ), ], ); }, ); } TextSpan _getTrimmedSpan(TextPainter painter, List spans) { final trimmedText = StringBuffer(); final remainingSpans = []; int charCount = 0; final maxChars = painter .getPositionForOffset(Offset(painter.width, painter.height)) .offset; for (final span in spans) { final spanText = span.text ?? ''; if (charCount + spanText.length <= maxChars) { remainingSpans.add(span); charCount += spanText.length; } else { final remaining = maxChars - charCount; if (remaining > 0) { remainingSpans.add( TextSpan(text: spanText.substring(0, remaining), style: span.style), ); } break; } } return TextSpan(children: remainingSpans, style: widget.style); } List _parseMarkdown(String text) { final lines = text.split('\n'); final spans = []; for (var line in lines) { final trimmed = line.trim(); // H1 if (trimmed.startsWith('# ') && trimmed.length > 2) { spans.add( TextSpan( text: '${trimmed.substring(2)}\n', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), ); continue; } // Нумерованный список if (RegExp(r'^\d+\.\s').hasMatch(trimmed)) { final match = RegExp(r'^(\d+)\.\s(.+)').firstMatch(trimmed); if (match != null) { spans.add( TextSpan( text: '${match.group(0)!}\n', style: const TextStyle(fontSize: 16), ), ); continue; } } // Маркированный список if (trimmed.startsWith('• ') && trimmed.length > 2) { spans.add( TextSpan( text: '• ${trimmed.substring(2)}\n', style: const TextStyle(fontSize: 16), ), ); continue; } // Жирный текст final boldRegex = RegExp(r'\*\*([^*]+)\*\*'); var remaining = line; var boldProcessed = []; int lastEnd = 0; for (final match in boldRegex.allMatches(line)) { if (match.start > lastEnd) { boldProcessed.add( TextSpan(text: line.substring(lastEnd, match.start)), ); } boldProcessed.add( TextSpan( text: match.group(1), style: const TextStyle(fontWeight: FontWeight.bold), ), ); lastEnd = match.end; } if (lastEnd < line.length) { boldProcessed.add(TextSpan(text: line.substring(lastEnd))); } boldProcessed.add(const TextSpan(text: '\n')); spans.addAll(boldProcessed); } return spans; } }