diff --git a/ac_web/lib/Pages/ReportDetails.dart b/ac_web/lib/Pages/ReportDetails.dart index c11b61d..dea4184 100644 --- a/ac_web/lib/Pages/ReportDetails.dart +++ b/ac_web/lib/Pages/ReportDetails.dart @@ -1,5 +1,6 @@ import 'package:ac_web/Api/Dtos/EReportType.dart'; import 'package:ac_web/Api/Dtos/ReportDto.dart'; +import 'package:ac_web/Widgets/MarkdownText.dart'; import 'package:flutter/material.dart'; /// Page that shows every detail of a single report, including the full text. @@ -40,10 +41,13 @@ class ReportDetails extends StatelessWidget{ _buildInfoRow(context, "Date", _formatTimestamp(local)), _buildInfoRow(context, "Id", report.id), const Divider(), - SelectableText( - report.text.isEmpty ? "This report has no text." : report.text, - style: Theme.of(context).textTheme.bodyMedium, - ), + if (report.text.isEmpty) + SelectableText( + "This report has no text.", + style: Theme.of(context).textTheme.bodyMedium, + ) + else + MarkdownText(data: report.text), ], ), ), diff --git a/ac_web/lib/Widgets/MarkdownText.dart b/ac_web/lib/Widgets/MarkdownText.dart new file mode 100644 index 0000000..2ca8d65 --- /dev/null +++ b/ac_web/lib/Widgets/MarkdownText.dart @@ -0,0 +1,306 @@ +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); + } +}