implemented the Ticker module with a native ProseMirror document renderer and integrated API support for structured content, navigation trees, and proxied files

This commit is contained in:
2026-07-09 00:51:08 +02:00
parent 1114291313
commit cedeb06569
57 changed files with 4758 additions and 43 deletions
@@ -0,0 +1,10 @@
# ProseMirror contract fixtures
These JSON files are copied verbatim from the MarianumConnect backend test
resources, which are the canonical source:
backend/services/ticker/src/test/resources/prosemirror-fixtures/
They pin the wire format shared between the backend content validator and this
app's renderer. When the backend fixtures change, re-copy them here — do not
hand-edit these files.
@@ -0,0 +1,51 @@
{
"type": "doc",
"content": [
{
"type": "callout",
"attrs": {
"variant": "info"
},
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Ein wichtiger Hinweis."
}
]
}
]
},
{
"type": "callout",
"attrs": {
"variant": "warning"
},
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Achtung."
}
]
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": null
}
}
]
}
@@ -0,0 +1,33 @@
{
"type": "doc",
"content": [
{
"type": "image",
"attrs": {
"src": "https://example.org/bild.png",
"alt": "Beschreibung",
"title": "Titel",
"width": "50%",
"align": "center",
"href": "https://example.org"
}
},
{
"type": "image",
"attrs": {
"src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"alt": "Einzelpixel",
"title": null,
"width": null,
"align": "left",
"href": null
}
},
{
"type": "paragraph",
"attrs": {
"textAlign": null
}
}
]
}
@@ -0,0 +1,113 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"textAlign": "left",
"level": 1
},
"content": [
{
"type": "text",
"text": "Vollständiges Dokument"
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": "justify"
},
"content": [
{
"type": "text",
"text": "Absatz mit "
},
{
"type": "text",
"marks": [
{
"type": "bold"
},
{
"type": "italic"
}
],
"text": "fett-kursivem"
},
{
"type": "text",
"text": " Text"
},
{
"type": "hardBreak"
},
{
"type": "text",
"text": "nach einem Umbruch."
}
]
},
{
"type": "blockquote",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Ein Zitat."
}
]
}
]
},
{
"type": "codeBlock",
"attrs": {
"language": "java"
},
"content": [
{
"type": "text",
"text": "System.out.println(\"Hallo\");"
}
]
},
{
"type": "horizontalRule"
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Letzter Punkt"
}
]
}
]
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": null
}
}
]
}
@@ -0,0 +1,92 @@
{
"type": "doc",
"content": [
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Erster Punkt"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Zweiter Punkt"
}
]
}
]
}
]
},
{
"type": "orderedList",
"attrs": {
"start": 1
},
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Eins"
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Zwei"
}
]
}
]
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": null
}
}
]
}
@@ -0,0 +1,99 @@
{
"type": "doc",
"content": [
{
"type": "table",
"content": [
{
"type": "tableRow",
"content": [
{
"type": "tableHeader",
"attrs": {
"colspan": 2,
"rowspan": 1,
"colwidth": [
120,
200
]
},
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Überschrift"
}
]
}
]
}
]
},
{
"type": "tableRow",
"content": [
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": [
120
]
},
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Zelle A"
}
]
}
]
},
{
"type": "tableCell",
"attrs": {
"colspan": 1,
"rowspan": 1,
"colwidth": [
200
]
},
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Zelle B"
}
]
}
]
}
]
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": null
}
}
]
}
@@ -0,0 +1,141 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"textAlign": "center",
"level": 2
},
"content": [
{
"type": "text",
"text": "Überschrift"
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Normal "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "fett"
},
{
"type": "text",
"text": " "
},
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "kursiv"
},
{
"type": "text",
"text": " "
},
{
"type": "text",
"marks": [
{
"type": "strike"
}
],
"text": "durchgestrichen"
},
{
"type": "text",
"text": " "
},
{
"type": "text",
"marks": [
{
"type": "underline"
}
],
"text": "unterstrichen"
},
{
"type": "text",
"text": " "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "code()"
},
{
"type": "text",
"text": " "
},
{
"type": "text",
"marks": [
{
"type": "highlight",
"attrs": {
"color": "#fef08a"
}
}
],
"text": "markiert"
},
{
"type": "text",
"text": " "
},
{
"type": "text",
"marks": [
{
"type": "textStyle",
"attrs": {
"fontSize": "20px"
}
}
],
"text": "größer"
},
{
"type": "text",
"text": " "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://example.org",
"target": "_blank",
"rel": "noopener noreferrer"
}
}
],
"text": "Verweis"
}
]
}
]
}
@@ -0,0 +1,226 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_document_view.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_node.dart';
const _pngDataUri =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=';
Map<String, dynamic> _kitchenSinkDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 1},
'content': [
{'type': 'text', 'text': 'Heading'},
],
},
{
'type': 'paragraph',
'content': [
{
'type': 'text',
'text': 'bold ',
'marks': [
{'type': 'bold'},
],
},
{
'type': 'text',
'text': 'link',
'marks': [
{
'type': 'link',
'attrs': {'href': 'https://example.org'},
},
],
},
{'type': 'hardBreak'},
{
'type': 'text',
'text': 'code',
'marks': [
{'type': 'code'},
],
},
],
},
{
'type': 'bulletList',
'content': [
{
'type': 'listItem',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'item'},
],
},
],
},
],
},
{
'type': 'callout',
'attrs': {'variant': 'info'},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'note'},
],
},
],
},
{
'type': 'codeBlock',
'content': [
{'type': 'text', 'text': 'x = 1'},
],
},
{'type': 'horizontalRule'},
{
'type': 'image',
'attrs': {'src': _pngDataUri, 'align': 'center', 'width': '40%'},
},
{
'type': 'unknownWidget',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'fallback child'},
],
},
],
},
{
'type': 'table',
'content': [
{
'type': 'tableRow',
'content': [
{
'type': 'tableHeader',
'attrs': {'colspan': 2},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'head'},
],
},
],
},
],
},
{
'type': 'tableRow',
'content': [
{
'type': 'tableCell',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'a'},
],
},
],
},
{
'type': 'tableCell',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'b'},
],
},
],
},
],
},
],
},
],
};
Widget _host(Brightness brightness, {void Function(String href)? onLinkTap}) {
return MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF993333),
brightness: brightness,
),
),
home: Scaffold(
body: SingleChildScrollView(
child: PmDocumentView(
doc: PmNode.fromJson(_kitchenSinkDoc()),
onLinkTap: onLinkTap,
),
),
),
);
}
void main() {
testWidgets('renders kitchen-sink doc in light theme without exception', (
tester,
) async {
await tester.pumpWidget(_host(Brightness.light));
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('Heading'), findsOneWidget);
expect(find.text('note'), findsOneWidget);
expect(find.text('fallback child'), findsOneWidget);
});
testWidgets('renders kitchen-sink doc in dark theme without exception', (
tester,
) async {
await tester.pumpWidget(_host(Brightness.dark));
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('Heading'), findsOneWidget);
});
testWidgets('link marks route through onLinkTap', (tester) async {
final tapped = <String>[];
await tester.pumpWidget(_host(Brightness.light, onLinkTap: tapped.add));
await tester.pump();
final richTexts = tester.widgetList<RichText>(find.byType(RichText));
for (final richText in richTexts) {
if (TapGestureRecognizerHarness.tapFirstLink(richText.text)) break;
}
expect(tapped, ['https://example.org']);
});
}
/// Walks a composed span tree and fires the first link recognizer it finds,
/// returning whether one was tapped.
class TapGestureRecognizerHarness {
static bool tapFirstLink(InlineSpan span) {
var tapped = false;
span.visitChildren((child) {
if (child is TextSpan) {
final recognizer = child.recognizer;
if (recognizer is TapGestureRecognizer && recognizer.onTap != null) {
recognizer.onTap!();
tapped = true;
return false;
}
}
return true;
});
return tapped;
}
}
@@ -0,0 +1,73 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_document_view.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_node.dart';
/// The canonical contract fixtures shared with the backend content validator
/// (see fixtures/README.md). Every one must parse into fully-typed nodes (no
/// PmUnknown in the content) and render in both themes without throwing.
const _fixtures = [
'callout.json',
'image.json',
'kitchen-sink.json',
'lists.json',
'table.json',
'text-marks.json',
];
Map<String, dynamic> _loadFixture(String name) {
final raw = File(
'test/widget/prosemirror/fixtures/$name',
).readAsStringSync();
return jsonDecode(raw) as Map<String, dynamic>;
}
bool _hasUnknown(PmNode node) {
if (node is PmUnknown) return true;
return node.children.any(_hasUnknown);
}
Widget _host(PmNode doc, Brightness brightness) => MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF993333),
brightness: brightness,
),
),
home: Scaffold(
body: SingleChildScrollView(child: PmDocumentView(doc: doc)),
),
);
void main() {
for (final fixture in _fixtures) {
test('$fixture parses without any PmUnknown in its content', () {
final doc = PmNode.fromJson(_loadFixture(fixture));
// The root `doc` node itself has no dedicated subclass and maps to
// PmUnknown by design; assert none of its content nodes are unknown.
expect(doc, isA<PmUnknown>());
expect(
doc.children.any(_hasUnknown),
isFalse,
reason: '$fixture contains an unrecognised node type',
);
});
testWidgets('$fixture renders in light + dark without exception', (
tester,
) async {
final doc = PmNode.fromJson(_loadFixture(fixture));
await tester.pumpWidget(_host(doc, Brightness.light));
await tester.pump();
expect(tester.takeException(), isNull);
await tester.pumpWidget(_host(doc, Brightness.dark));
await tester.pump();
expect(tester.takeException(), isNull);
});
}
}
@@ -0,0 +1,355 @@
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/widget/prosemirror/pm_node.dart';
/// A 1x1 transparent PNG as a base64 data URI (valid).
const _pngDataUri =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII=';
/// A realistic TipTap `getJSON()` document exercising the whole vocabulary.
Map<String, dynamic> _kitchenSinkDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 1, 'textAlign': 'center'},
'content': [
{'type': 'text', 'text': 'Title'},
],
},
{
'type': 'paragraph',
'attrs': {'textAlign': 'left'},
'content': [
{
'type': 'text',
'text': 'bold',
'marks': [
{'type': 'bold'},
],
},
{'type': 'text', 'text': ' and '},
{
'type': 'text',
'text': 'link',
'marks': [
{
'type': 'link',
'attrs': {'href': 'https://example.org', 'target': '_blank'},
},
],
},
{'type': 'hardBreak'},
{
'type': 'text',
'text': 'sized highlighted',
'marks': [
{
'type': 'textStyle',
'attrs': {'fontSize': '1.5em'},
},
{
'type': 'highlight',
'attrs': {'color': '#ff0'},
},
],
},
],
},
{
'type': 'bulletList',
'content': [
{
'type': 'listItem',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'one'},
],
},
],
},
],
},
{
'type': 'orderedList',
'attrs': {'start': 3},
'content': [
{
'type': 'listItem',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'three'},
],
},
],
},
],
},
{
'type': 'blockquote',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'quoted'},
],
},
],
},
{
'type': 'codeBlock',
'attrs': {'language': 'dart'},
'content': [
{'type': 'text', 'text': 'void main() {}'},
],
},
{'type': 'horizontalRule'},
{
'type': 'callout',
'attrs': {'variant': 'warning'},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'careful'},
],
},
],
},
{
'type': 'image',
'attrs': {
'src': _pngDataUri,
'alt': 'dot',
'align': 'center',
'width': '50%',
'href': 'https://example.org',
},
},
{
'type': 'table',
'content': [
{
'type': 'tableRow',
'content': [
{
'type': 'tableHeader',
'attrs': {'colspan': 2, 'rowspan': 1},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'head'},
],
},
],
},
],
},
{
'type': 'tableRow',
'content': [
{
'type': 'tableCell',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'a'},
],
},
],
},
{
'type': 'tableCell',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'b'},
],
},
],
},
],
},
],
},
],
};
bool _hasUnknown(PmNode node) {
if (node is PmUnknown) return true;
return node.children.any(_hasUnknown);
}
void main() {
test('kitchen-sink doc parses without any PmUnknown', () {
final doc = PmNode.fromJson(_kitchenSinkDoc());
// The root `doc` node has no dedicated subclass by design and maps to
// PmUnknown; assert instead that no *content* node is unknown.
expect(doc, isA<PmUnknown>());
expect(doc.children.any(_hasUnknown), isFalse);
});
test('every top-level node maps to its typed subclass', () {
final doc = PmNode.fromJson(_kitchenSinkDoc());
final types = doc.children.map((n) => n.runtimeType).toList();
expect(types, [
PmHeading,
PmParagraph,
PmBulletList,
PmOrderedList,
PmBlockquote,
PmCodeBlock,
PmHorizontalRule,
PmCallout,
PmImage,
PmTable,
]);
});
test('unknown node type becomes PmUnknown without throwing', () {
final node = PmNode.fromJson({
'type': 'youTubeEmbed',
'attrs': {'videoId': 'abc'},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'caption'},
],
},
],
});
expect(node, isA<PmUnknown>());
expect((node as PmUnknown).rawType, 'youTubeEmbed');
expect(node.children.single, isA<PmParagraph>());
});
test('heading level and align parse and clamp', () {
final node =
PmNode.fromJson({
'type': 'heading',
'attrs': {'level': 9, 'textAlign': 'right'},
})
as PmHeading;
expect(node.level, 6);
expect(node.align, TextAlign.right);
});
test('orderedList start defaults to 1 and honours attr', () {
final withStart =
PmNode.fromJson({
'type': 'orderedList',
'attrs': {'start': 5},
})
as PmOrderedList;
final without = PmNode.fromJson({'type': 'orderedList'}) as PmOrderedList;
expect(withStart.start, 5);
expect(without.start, 1);
});
test('marks parse with attrs; unknown marks are dropped tolerantly', () {
final node =
PmNode.fromJson({
'type': 'text',
'text': 'x',
'marks': [
{'type': 'bold'},
{
'type': 'link',
'attrs': {'href': 'mailto:a@b.de'},
},
{'type': 42},
{
'type': 'superscript',
'attrs': {'foo': 'bar'},
},
],
})
as PmText;
final markTypes = node.marks.map((m) => m.type).toList();
expect(markTypes, contains('bold'));
expect(markTypes, contains('link'));
expect(markTypes, contains('superscript'));
expect(markTypes, isNot(contains('')));
final link = node.marks.firstWhere((m) => m.type == 'link');
expect(link.attrs['href'], 'mailto:a@b.de');
});
test('valid base64 data image is decoded once into bytes', () {
final image =
PmNode.fromJson({
'type': 'image',
'attrs': {'src': _pngDataUri},
})
as PmImage;
expect(image.bytes, isNotNull);
expect(image.bytes!.isNotEmpty, isTrue);
});
test('broken base64 data image does not crash and yields null bytes', () {
final image =
PmNode.fromJson({
'type': 'image',
'attrs': {'src': 'data:image/png;base64,@@@not-base64@@@'},
})
as PmImage;
expect(image.bytes, isNull);
expect(image.src, startsWith('data:image/png'));
});
test('network image keeps null bytes', () {
final image =
PmNode.fromJson({
'type': 'image',
'attrs': {'src': 'https://example.org/a.png'},
})
as PmImage;
expect(image.bytes, isNull);
});
test('callout variant parses; invalid falls back to info', () {
final ok =
PmNode.fromJson({
'type': 'callout',
'attrs': {'variant': 'success'},
})
as PmCallout;
final bad =
PmNode.fromJson({
'type': 'callout',
'attrs': {'variant': 'nope'},
})
as PmCallout;
expect(ok.variant, PmCalloutVariant.success);
expect(bad.variant, PmCalloutVariant.info);
});
test('table cells carry header flag and spans', () {
final table =
PmNode.fromJson(
(_kitchenSinkDoc()['content'] as List).last
as Map<String, dynamic>,
)
as PmTable;
final firstRow = table.children.first as PmTableRow;
final header = firstRow.children.first as PmTableCell;
expect(header.header, isTrue);
expect(header.colspan, 2);
});
test('parsing survives a JSON string round-trip', () {
final raw = jsonEncode(_kitchenSinkDoc());
final decoded = jsonDecode(raw) as Map<String, dynamic>;
final doc = PmNode.fromJson(decoded);
expect(doc.children.any(_hasUnknown), isFalse);
});
}