import 'package:flutter/material.dart'; /// Renders a markdown string as formatted widgets. /// /// Supports the markdown subset that the reports actually use: /// headings (#..######), unordered and ordered lists, block quotes, /// fenced code blocks, horizontal rules and the inline styles /// bold, italic, strikethrough, inline code and links. class MarkdownText extends StatelessWidget{ const MarkdownText({super.key, required this.data}); final String data; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: _buildBlocks(context), ); } List _buildBlocks(BuildContext context) { final List blocks = []; final List lines = data.replaceAll('\r\n', '\n').split('\n'); final List paragraph = []; void flushParagraph() { if (paragraph.isEmpty) { return; } blocks.add(_paragraph(context, paragraph.join(' '))); paragraph.clear(); } for (int i = 0; i < lines.length; i++) { final String line = lines[i]; final String trimmed = line.trim(); // Fenced code block if (trimmed.startsWith('```')) { flushParagraph(); final List code = []; i++; while (i < lines.length && !lines[i].trim().startsWith('```')) { code.add(lines[i]); i++; } blocks.add(_codeBlock(context, code.join('\n'))); continue; } // Empty line ends the current paragraph if (trimmed.isEmpty) { flushParagraph(); continue; } // Horizontal rule if (RegExp(r'^(\*\s*){3,}$|^(-\s*){3,}$|^(_\s*){3,}$').hasMatch(trimmed)) { flushParagraph(); blocks.add(const Divider()); continue; } // Heading final RegExpMatch? heading = RegExp(r'^(#{1,6})\s+(.*)$').firstMatch(trimmed); if (heading != null) { flushParagraph(); blocks.add(_heading(context, heading.group(1)!.length, heading.group(2)!)); continue; } // Block quote if (trimmed.startsWith('>')) { flushParagraph(); blocks.add(_quote(context, trimmed.substring(1).trim())); continue; } // Unordered list item final RegExpMatch? bullet = RegExp(r'^[-*+]\s+(.*)$').firstMatch(trimmed); if (bullet != null) { flushParagraph(); blocks.add(_listItem(context, '\u2022', bullet.group(1)!, _indentOf(line))); continue; } // Ordered list item final RegExpMatch? numbered = RegExp(r'^(\d+)[.)]\s+(.*)$').firstMatch(trimmed); if (numbered != null) { flushParagraph(); blocks.add(_listItem(context, '${numbered.group(1)}.', numbered.group(2)!, _indentOf(line))); continue; } paragraph.add(trimmed); } flushParagraph(); return blocks; } /// Number of leading spaces of a line, a tab counts as two spaces. int _indentOf(String line) { int spaces = 0; for (int i = 0; i < line.length; i++) { if (line[i] == ' ') { spaces++; } else if (line[i] == '\t') { spaces += 2; } else { break; } } return spaces; } Widget _paragraph(BuildContext context, String text) { return SelectableText.rich( _inline(context, text, Theme.of(context).textTheme.bodyMedium), ); } Widget _heading(BuildContext context, int level, String text) { final TextTheme theme = Theme.of(context).textTheme; TextStyle? style; switch (level) { case 1: style = theme.headlineMedium; case 2: style = theme.headlineSmall; case 3: style = theme.titleLarge; case 4: style = theme.titleMedium; default: style = theme.titleSmall; } return Padding( padding: EdgeInsetsGeometry.only(top: 8), child: SelectableText.rich(_inline(context, text, style)), ); } Widget _quote(BuildContext context, String text) { final Color color = Theme.of(context).colorScheme.primary; return Container( padding: EdgeInsetsGeometry.fromLTRB(12, 4, 0, 4), decoration: BoxDecoration( border: Border(left: BorderSide(color: color, width: 4)), ), child: SelectableText.rich( _inline( context, text, Theme.of(context).textTheme.bodyMedium?.copyWith(fontStyle: FontStyle.italic), ), ), ); } Widget _listItem(BuildContext context, String marker, String text, int indent) { return Padding( padding: EdgeInsetsGeometry.only(left: 8.0 + indent * 8.0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, spacing: 8, children: [ Text(marker, style: Theme.of(context).textTheme.bodyMedium), Expanded( child: SelectableText.rich( _inline(context, text, Theme.of(context).textTheme.bodyMedium), ), ), ], ), ); } Widget _codeBlock(BuildContext context, String code) { return Container( width: double.infinity, padding: EdgeInsetsGeometry.all(12), decoration: BoxDecoration( color: Colors.black45, borderRadius: BorderRadius.circular(8), ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SelectableText( code, style: Theme.of(context).textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), ), ), ); } /// Parses the inline markdown syntax of a single block of text. TextSpan _inline(BuildContext context, String text, TextStyle? baseStyle) { final List spans = []; final StringBuffer buffer = StringBuffer(); void flush() { if (buffer.isEmpty) { return; } spans.add(TextSpan(text: buffer.toString())); buffer.clear(); } final RegExp pattern = RegExp( r'(\*\*\*|___)(.+?)\1' // bold + italic r'|(\*\*|__)(.+?)\3' // bold r'|(\*|_)(.+?)\5' // italic r'|~~(.+?)~~' // strike through r'|`([^`]+)`' // inline code r'|!?\[([^\]]*)\]\(([^)]*)\)', // link / image ); int index = 0; for (final RegExpMatch match in pattern.allMatches(text)) { if (match.start > index) { buffer.write(text.substring(index, match.start)); } flush(); if (match.group(2) != null) { spans.add(TextSpan( text: match.group(2), style: const TextStyle(fontWeight: FontWeight.bold, fontStyle: FontStyle.italic), )); } else if (match.group(4) != null) { spans.add(TextSpan( text: match.group(4), style: const TextStyle(fontWeight: FontWeight.bold), )); } else if (match.group(6) != null) { spans.add(TextSpan( text: match.group(6), style: const TextStyle(fontStyle: FontStyle.italic), )); } else if (match.group(7) != null) { spans.add(TextSpan( text: match.group(7), style: const TextStyle(decoration: TextDecoration.lineThrough), )); } else if (match.group(8) != null) { spans.add(TextSpan( text: match.group(8), style: TextStyle( fontFamily: 'monospace', backgroundColor: Colors.black45, ), )); } else { final String label = match.group(9) ?? ''; final String target = match.group(10) ?? ''; spans.add(TextSpan( text: label.isEmpty ? target : label, style: TextStyle( color: Theme.of(context).colorScheme.primary, decoration: TextDecoration.underline, ), )); } index = match.end; } if (index < text.length) { buffer.write(text.substring(index)); } flush(); return TextSpan(style: baseStyle, children: spans); } }