Included a Markdown Text component and used it to draw the ReportDetails page

This commit is contained in:
2026-08-31 15:35:17 +02:00
parent a2528def24
commit 9c60b64bf0
2 changed files with 314 additions and 4 deletions
+8 -4
View File
@@ -1,5 +1,6 @@
import 'package:ac_web/Api/Dtos/EReportType.dart'; import 'package:ac_web/Api/Dtos/EReportType.dart';
import 'package:ac_web/Api/Dtos/ReportDto.dart'; import 'package:ac_web/Api/Dtos/ReportDto.dart';
import 'package:ac_web/Widgets/MarkdownText.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Page that shows every detail of a single report, including the full text. /// 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, "Date", _formatTimestamp(local)),
_buildInfoRow(context, "Id", report.id), _buildInfoRow(context, "Id", report.id),
const Divider(), const Divider(),
SelectableText( if (report.text.isEmpty)
report.text.isEmpty ? "This report has no text." : report.text, SelectableText(
style: Theme.of(context).textTheme.bodyMedium, "This report has no text.",
), style: Theme.of(context).textTheme.bodyMedium,
)
else
MarkdownText(data: report.text),
], ],
), ),
), ),
+306
View File
@@ -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<Widget> _buildBlocks(BuildContext context) {
final List<Widget> blocks = <Widget>[];
final List<String> lines = data.replaceAll('\r\n', '\n').split('\n');
final List<String> paragraph = <String>[];
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<String> code = <String>[];
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<InlineSpan> spans = <InlineSpan>[];
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);
}
}