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
+112
View File
@@ -0,0 +1,112 @@
import '../../marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
/// Demo fixtures for the ticker module — a small, self-contained example doc so
/// the "Aktuelles" surface and the page tree are populated without any network.
class DemoTicker {
const DemoTicker._();
static Map<String, dynamic> _welcomeDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 1},
'content': [
{'type': 'text', 'text': 'Willkommen beim Ticker'},
],
},
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'Hier erscheinen aktuelle Informationen '},
{
'type': 'text',
'text': 'der Schule',
'marks': [
{'type': 'bold'},
],
},
{'type': 'text', 'text': '.'},
],
},
{
'type': 'callout',
'attrs': {'variant': 'info'},
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'Dies ist eine Beispiel-Meldung.'},
],
},
],
},
],
};
static Map<String, dynamic> _pageDoc() => {
'type': 'doc',
'content': [
{
'type': 'heading',
'attrs': {'level': 2},
'content': [
{'type': 'text', 'text': 'Über diese Seite'},
],
},
{
'type': 'paragraph',
'content': [
{
'type': 'text',
'text': 'Eine Beispielseite mit nativ gerendertem Inhalt.',
},
],
},
],
};
static TickerResponse ticker() => TickerResponse(
schemaVersion: 1,
available: true,
hash: 'demo-ticker',
publishedAt: DateTime.now().toIso8601String(),
webUrl: '/ticker',
content: _welcomeDoc(),
);
static TickerNavResponse nav() => TickerNavResponse(
schemaVersion: 1,
navHash: 'demo-nav',
sections: [
TickerNavSection(
title: 'Informationen',
pages: [
TickerNavPage(
title: 'Über die App',
slug: 'ueber-die-app',
kind: TickerPageKind.content,
hash: 'demo-page',
),
TickerNavPage(
title: 'Schulwebseite',
slug: 'schulwebseite',
kind: TickerPageKind.redirect,
externalUrl: 'https://marianum-fulda.de',
),
],
),
],
);
static TickerPageResponse page(String slug) => TickerPageResponse(
schemaVersion: 1,
slug: slug,
title: 'Über die App',
kind: TickerPageKind.content,
content: _pageDoc(),
webUrl: '/ticker/p/$slug',
);
}
@@ -0,0 +1,19 @@
import 'app_exception.dart';
/// Raised when a CONTENT ticker page has no native ProseMirror payload yet
/// (404 `CONTENT_UNAVAILABLE`). Carries the site-relative [webUrl] so the UI
/// can offer to open the page in the browser instead. Not retryable — the
/// content only appears after an admin re-saves the page.
class TickerContentUnavailableException extends AppException {
final String? webUrl;
const TickerContentUnavailableException({
this.webUrl,
super.technicalDetails,
}) : super(
userMessage:
'Diese Seite ist in der App noch nicht verfügbar. '
'Du kannst sie im Browser öffnen.',
allowRetry: false,
);
}
@@ -55,6 +55,8 @@ enum BreakerArea {
files,
@JsonValue('NEWS')
news,
@JsonValue('TICKER')
ticker,
@JsonValue('ROOMPLAN')
roomPlan,
@JsonValue('GRADES')
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
BreakerArea.talk: 'TALK',
BreakerArea.files: 'FILES',
BreakerArea.news: 'NEWS',
BreakerArea.ticker: 'TICKER',
BreakerArea.roomPlan: 'ROOMPLAN',
BreakerArea.grades: 'GRADES',
BreakerArea.holidays: 'HOLIDAYS',
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'get_ticker_response.dart';
/// Fetches the current "Aktuelles" ticker post from
/// `GET /api/mobile/v1/ticker`. Bearer token is attached by the shared dio.
class GetTicker {
final Dio _dio;
GetTicker({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('ticker'),
);
return TickerResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,44 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_response.g.dart';
/// The "Aktuelles" ticker post from `GET /api/mobile/v1/ticker`.
///
/// [available] is false when the current post has no ProseMirror JSON yet (a
/// legacy post not re-saved since the dual-write rollout); the backend still
/// returns 200 with `content: null` so the home surface degrades to a
/// "open in browser" hint rather than an error. Unknown/missing flags default
/// to a safe empty state.
@JsonSerializable()
class TickerResponse {
@JsonKey(defaultValue: 1)
final int schemaVersion;
@JsonKey(defaultValue: false)
final bool available;
final String? hash;
final String? publishedAt;
/// Site-relative link to the web ticker (e.g. `/ticker`); the app prefixes
/// the active Marianum-Connect base URL.
@JsonKey(defaultValue: '/ticker')
final String webUrl;
/// Nested ProseMirror document (`{ type: doc, content: [...] }`) or null when
/// [available] is false. Parsed to a `PmNode` at render time.
final Map<String, dynamic>? content;
TickerResponse({
required this.schemaVersion,
required this.available,
this.hash,
this.publishedAt,
required this.webUrl,
this.content,
});
factory TickerResponse.fromJson(Map<String, dynamic> json) =>
_$TickerResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerResponseToJson(this);
}
@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerResponse _$TickerResponseFromJson(Map<String, dynamic> json) =>
TickerResponse(
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
available: json['available'] as bool? ?? false,
hash: json['hash'] as String?,
publishedAt: json['publishedAt'] as String?,
webUrl: json['webUrl'] as String? ?? '/ticker',
content: json['content'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$TickerResponseToJson(TickerResponse instance) =>
<String, dynamic>{
'schemaVersion': instance.schemaVersion,
'available': instance.available,
'hash': instance.hash,
'publishedAt': instance.publishedAt,
'webUrl': instance.webUrl,
'content': instance.content,
};
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'get_ticker_nav_response.dart';
/// Fetches the filtered ticker page tree from
/// `GET /api/mobile/v1/ticker/pages`.
class GetTickerNav {
final Dio _dio;
GetTickerNav({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerNavResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('ticker/pages'),
);
return TickerNavResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,78 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_nav_response.g.dart';
/// The filtered ticker page tree from `GET /api/mobile/v1/ticker/pages`.
///
/// The backend already drops unpublished pages, pages the user may not see, and
/// CONTENT pages without ProseMirror JSON — the app renders whatever arrives.
@JsonSerializable(explicitToJson: true)
class TickerNavResponse {
@JsonKey(defaultValue: 1)
final int schemaVersion;
@JsonKey(defaultValue: '')
final String navHash;
@JsonKey(defaultValue: [])
final List<TickerNavSection> sections;
TickerNavResponse({
required this.schemaVersion,
required this.navHash,
required this.sections,
});
factory TickerNavResponse.fromJson(Map<String, dynamic> json) =>
_$TickerNavResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerNavResponseToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TickerNavSection {
@JsonKey(defaultValue: '')
final String title;
@JsonKey(defaultValue: [])
final List<TickerNavPage> pages;
TickerNavSection({required this.title, required this.pages});
factory TickerNavSection.fromJson(Map<String, dynamic> json) =>
_$TickerNavSectionFromJson(json);
Map<String, dynamic> toJson() => _$TickerNavSectionToJson(this);
}
/// A single navigable page. [kind] is one of `CONTENT`, `REDIRECT`,
/// `PROXIED_FILE` (see [TickerPageKind]); the extra fields are populated per
/// kind (REDIRECT → [externalUrl], PROXIED_FILE → [fileUrl]).
@JsonSerializable()
class TickerNavPage {
@JsonKey(defaultValue: '')
final String title;
@JsonKey(defaultValue: '')
final String slug;
@JsonKey(defaultValue: 'CONTENT')
final String kind;
final String? hash;
final String? externalUrl;
final bool? externalUrlNewTab;
final String? fileUrl;
TickerNavPage({
required this.title,
required this.slug,
required this.kind,
this.hash,
this.externalUrl,
this.externalUrlNewTab,
this.fileUrl,
});
factory TickerNavPage.fromJson(Map<String, dynamic> json) =>
_$TickerNavPageFromJson(json);
Map<String, dynamic> toJson() => _$TickerNavPageToJson(this);
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_nav_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerNavResponse _$TickerNavResponseFromJson(Map<String, dynamic> json) =>
TickerNavResponse(
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
navHash: json['navHash'] as String? ?? '',
sections:
(json['sections'] as List<dynamic>?)
?.map((e) => TickerNavSection.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$TickerNavResponseToJson(TickerNavResponse instance) =>
<String, dynamic>{
'schemaVersion': instance.schemaVersion,
'navHash': instance.navHash,
'sections': instance.sections.map((e) => e.toJson()).toList(),
};
TickerNavSection _$TickerNavSectionFromJson(Map<String, dynamic> json) =>
TickerNavSection(
title: json['title'] as String? ?? '',
pages:
(json['pages'] as List<dynamic>?)
?.map((e) => TickerNavPage.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
Map<String, dynamic> _$TickerNavSectionToJson(TickerNavSection instance) =>
<String, dynamic>{
'title': instance.title,
'pages': instance.pages.map((e) => e.toJson()).toList(),
};
TickerNavPage _$TickerNavPageFromJson(Map<String, dynamic> json) =>
TickerNavPage(
title: json['title'] as String? ?? '',
slug: json['slug'] as String? ?? '',
kind: json['kind'] as String? ?? 'CONTENT',
hash: json['hash'] as String?,
externalUrl: json['externalUrl'] as String?,
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
fileUrl: json['fileUrl'] as String?,
);
Map<String, dynamic> _$TickerNavPageToJson(TickerNavPage instance) =>
<String, dynamic>{
'title': instance.title,
'slug': instance.slug,
'kind': instance.kind,
'hash': instance.hash,
'externalUrl': instance.externalUrl,
'externalUrlNewTab': instance.externalUrlNewTab,
'fileUrl': instance.fileUrl,
};
@@ -0,0 +1,65 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'get_ticker_page_response.dart';
/// Fetches a single ticker page from
/// `GET /api/mobile/v1/ticker/pages/{slug}`.
///
/// A CONTENT page whose ProseMirror JSON hasn't been backfilled yet answers
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error.
class GetTickerPage {
final String slug;
final Dio _dio;
GetTickerPage(this.slug, {Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerPageResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve(
'ticker/pages/${Uri.encodeComponent(slug)}',
),
);
return TickerPageResponse.fromJson(response.data!);
} on DioException catch (e) {
throw _mapError(e);
}
}
Object _mapError(DioException e) {
final response = e.response;
if (response?.statusCode == 404) {
final body = _asMap(response?.data);
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
final webUrl = body['webUrl'];
return TickerContentUnavailableException(
webUrl: webUrl is String ? webUrl : null,
technicalDetails: 'MC 404 CONTENT_UNAVAILABLE for slug=$slug',
);
}
}
return mapMarianumConnectError(e);
}
Map<String, dynamic>? _asMap(dynamic data) {
if (data is Map<String, dynamic>) return data;
if (data is String && data.isNotEmpty) {
try {
final decoded = jsonDecode(data);
if (decoded is Map<String, dynamic>) return decoded;
} catch (_) {
return null;
}
}
return null;
}
}
@@ -0,0 +1,60 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_page_response.g.dart';
/// Canonical ticker page kinds. Kept as plain strings on the wire so an unknown
/// future kind never breaks JSON parsing.
abstract class TickerPageKind {
static const String content = 'CONTENT';
static const String redirect = 'REDIRECT';
static const String proxiedFile = 'PROXIED_FILE';
}
/// A single ticker page from `GET /api/mobile/v1/ticker/pages/{slug}`.
///
/// The populated fields depend on [kind]:
/// - `CONTENT` → [content] (nested ProseMirror document)
/// - `REDIRECT` → [externalUrl]
/// - `PROXIED_FILE` → [fileUrl], [contentType], [filename]
@JsonSerializable()
class TickerPageResponse {
@JsonKey(defaultValue: 1)
final int schemaVersion;
final String? slug;
final String? title;
@JsonKey(defaultValue: 'CONTENT')
final String kind;
final Map<String, dynamic>? content;
final String? externalUrl;
final bool? externalUrlNewTab;
final String? fileUrl;
final String? contentType;
final String? filename;
final String? hash;
final String? webUrl;
TickerPageResponse({
required this.schemaVersion,
this.slug,
this.title,
required this.kind,
this.content,
this.externalUrl,
this.externalUrlNewTab,
this.fileUrl,
this.contentType,
this.filename,
this.hash,
this.webUrl,
});
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
_$TickerPageResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerPageResponseToJson(this);
}
@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_page_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
TickerPageResponse(
schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1,
slug: json['slug'] as String?,
title: json['title'] as String?,
kind: json['kind'] as String? ?? 'CONTENT',
content: json['content'] as Map<String, dynamic>?,
externalUrl: json['externalUrl'] as String?,
externalUrlNewTab: json['externalUrlNewTab'] as bool?,
fileUrl: json['fileUrl'] as String?,
contentType: json['contentType'] as String?,
filename: json['filename'] as String?,
hash: json['hash'] as String?,
webUrl: json['webUrl'] as String?,
);
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
<String, dynamic>{
'schemaVersion': instance.schemaVersion,
'slug': instance.slug,
'title': instance.title,
'kind': instance.kind,
'content': instance.content,
'externalUrl': instance.externalUrl,
'externalUrlNewTab': instance.externalUrlNewTab,
'fileUrl': instance.fileUrl,
'contentType': instance.contentType,
'filename': instance.filename,
'hash': instance.hash,
'webUrl': instance.webUrl,
};
@@ -0,0 +1,35 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
/// `GET /api/mobile/v1/ticker/pages/{slug}/file`.
///
/// Goes through the shared MC dio so the bearer token is attached automatically
/// (server enforces page visibility); the bytes are handed to `SfPdfViewer.memory`
/// so no auth header has to be plumbed into the viewer itself.
class GetTickerPageFile {
final String slug;
final Dio _dio;
GetTickerPageFile(this.slug, {Dio? dio})
: _dio = dio ?? MarianumConnectApi.dio();
Future<Uint8List> run() async {
try {
final response = await _dio.get<List<int>>(
MarianumConnectEndpoint.resolve(
'ticker/pages/${Uri.encodeComponent(slug)}/file',
),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'get_ticker_sync_response.dart';
/// Fetches the ticker/nav change hashes from
/// `GET /api/mobile/v1/ticker/sync`.
class GetTickerSync {
final Dio _dio;
GetTickerSync({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerSyncResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('ticker/sync'),
);
return TickerSyncResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_sync_response.g.dart';
/// Cheap change-detection poll from `GET /api/mobile/v1/ticker/sync`. Both
/// hashes let the app decide whether the ticker post and/or the page tree need
/// a full refetch without paying for the full payloads.
@JsonSerializable()
class TickerSyncResponse {
final String? tickerHash;
final String? navHash;
TickerSyncResponse({this.tickerHash, this.navHash});
factory TickerSyncResponse.fromJson(Map<String, dynamic> json) =>
_$TickerSyncResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerSyncResponseToJson(this);
}
@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_sync_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerSyncResponse _$TickerSyncResponseFromJson(Map<String, dynamic> json) =>
TickerSyncResponse(
tickerHash: json['tickerHash'] as String?,
navHash: json['navHash'] as String?,
);
Map<String, dynamic> _$TickerSyncResponseToJson(TickerSyncResponse instance) =>
<String, dynamic>{
'tickerHash': instance.tickerHash,
'navHash': instance.navHash,
};
+74
View File
@@ -1,9 +1,13 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart';
import '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../main.dart';
import '../model/account_data.dart';
@@ -30,6 +34,7 @@ import '../view/pages/share_intent/share_target_page.dart';
import '../view/pages/talk/chat_view.dart';
import '../view/pages/talk/details/message_reactions.dart';
import '../view/pages/talk/talk_navigator.dart';
import '../view/pages/ticker/ticker_page_view.dart';
import '../view/pages/timetable/custom_events/custom_events_view.dart';
import '../widget/debug/cache_view.dart';
import '../widget/file_viewer.dart';
@@ -136,6 +141,75 @@ class AppRoutes {
);
}
/// Opens a ticker page (CONTENT or PROXIED_FILE) as a standalone detail
/// screen. Used for deep links from outside the ticker module — inside the
/// module pages open in-place instead.
static void openTickerPage(
BuildContext context, {
required String slug,
String? title,
}) {
pushScreen(
context,
withNavBar: false,
screen: TickerPageView(slug: slug, title: title),
);
}
/// Resolves a link tapped inside ticker content. Links that point at another
/// ticker page (absolute against the active base URL, or site-relative
/// `/ticker/p/{slug}`) open in-app; everything else goes to the external
/// launcher.
static void openTickerLink(BuildContext context, String href) {
final slug = tickerSlugOf(href);
if (slug != null && slug.isNotEmpty) {
openTickerPage(context, slug: slug);
return;
}
unawaited(openExternalUrl(href));
}
/// Extracts the ticker page slug from a link that points at another ticker
/// page (absolute against the active base URL, or site-relative
/// `/ticker/p/{slug}`), or null for any other link. Exposed so the in-place
/// ticker navigation can reuse the same detection instead of duplicating it.
static String? tickerSlugOf(String href) {
const marker = '/ticker/p/';
final base = MarianumConnectEndpoint.current();
String? rest;
if (href.startsWith('$base$marker')) {
rest = href.substring('$base$marker'.length);
} else if (href.startsWith(marker)) {
rest = href.substring(marker.length);
}
if (rest == null) return null;
rest = rest.split('#').first.split('?').first;
if (rest.isEmpty) return null;
return Uri.decodeComponent(rest);
}
/// Launches an external URL, restricted to safe schemes (http/https/mailto/tel).
static Future<void> openExternalUrl(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
const allowed = {'http', 'https', 'mailto', 'tel'};
if (!allowed.contains(uri.scheme.toLowerCase())) return;
if (await canLaunchUrlString(url)) {
await launchUrlString(url, mode: LaunchMode.externalApplication);
}
}
/// Opens a possibly site-relative web URL (e.g. the ticker `webUrl`) by
/// prefixing the active Marianum-Connect base URL when needed.
static Future<void> openWebUrl(String relativeOrAbsolute) {
if (relativeOrAbsolute.startsWith('http')) {
return openExternalUrl(relativeOrAbsolute);
}
return openExternalUrl(
'${MarianumConnectEndpoint.current()}$relativeOrAbsolute',
);
}
static void openQrShare(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const QrShareView());
}
+63 -10
View File
@@ -5,6 +5,7 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import '../../../routing/app_routes.dart';
import '../../../storage/modules_settings.dart';
import '../../../view/pages/files/files.dart';
import '../../../view/pages/grade_averages/grade_averages_view.dart';
import '../../../view/pages/holidays/holidays_view.dart';
@@ -12,6 +13,7 @@ import '../../../view/pages/marianum_dates/marianum_dates_view.dart';
import '../../../view/pages/marianum_message/marianum_message_list_view.dart';
import '../../../view/pages/more/roomplan/roomplan.dart';
import '../../../view/pages/talk/chat_list.dart';
import '../../../view/pages/ticker/ticker_view.dart';
import '../../../view/pages/timetable/timetable.dart';
import '../../../widget/breaker/breaker.dart';
import '../../../widget/centered_leading.dart';
@@ -48,6 +50,15 @@ class AppModule {
breakerArea: BreakerArea.timetable,
create: Timetable.new,
),
Modules.ticker: AppModule(
Modules.ticker,
name: 'Ticker',
// Icons.newspaper is already taken by the "Marianum Message" module;
// use feed to keep the two visually distinct.
icon: () => Icon(Icons.feed),
breakerArea: BreakerArea.ticker,
create: TickerView.new,
),
Modules.talk: AppModule(
Modules.talk,
name: 'Talk',
@@ -136,13 +147,59 @@ class AppModule {
}
return {
for (var element in settings.val().modulesSettings.moduleOrder.where(
(element) => available.containsKey(element),
))
for (var element in effectiveModuleOrder(
settings.val().modulesSettings,
).where((element) => available.containsKey(element)))
element: available[element]!,
};
}
// A persisted moduleOrder predates modules added in newer app versions.
// Deriving the effective order at read time (instead of relying on a heal
// during hydration) makes it impossible for a module to vanish from the
// bottom bar, the "Mehr" menu and the settings list at once. Missing
// modules are inserted right after their closest declared predecessor so
// they land at their default position (e.g. the ticker between Stundenplan
// and Talk) instead of at the end of the "Mehr" menu.
static List<Modules> effectiveModuleOrder(ModulesSettings settings) {
final seen = <Modules>{};
final order = [
for (final m in settings.moduleOrder)
if (seen.add(m)) m,
];
for (final missing in Modules.values) {
if (!seen.add(missing)) continue;
var insertAt = 0;
for (final predecessor in Modules.values.takeWhile(
(m) => m != missing,
)) {
final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1;
}
order.insert(insertAt, missing);
}
return order;
}
// The settings list displays a capability-filtered subset, so reorder
// indices refer to that subset; the move is applied there and merged back
// into the full order (non-displayed modules keep their relative slots).
static List<Modules> reorderModuleOrder({
required List<Modules> displayed,
required List<Modules> effective,
required int oldIndex,
required int newIndex,
}) {
final moved = List<Modules>.from(displayed);
moved.insert(newIndex, moved.removeAt(oldIndex));
final displayedSet = displayed.toSet();
var moveIndex = 0;
return [
for (final m in effective)
displayedSet.contains(m) ? moved[moveIndex++] : m,
];
}
static const int minBottomBarSlots = 3;
static const int maxBottomBarSlots = 5;
@@ -152,14 +209,9 @@ class AppModule {
int desired;
if (settings.autoFillBottomBar) {
final width = MediaQuery.of(context).size.width;
if (width >= 840) {
desired = 5;
} else if (width >= 600) {
// 4 content tabs so the bar shows 5 entries including "Mehr" — enough
// for the Ticker module without pushing "Dateien" into the overflow.
desired = 4;
} else {
desired = 3;
}
} else {
desired = settings.fixedBottomBarSlots;
}
@@ -225,6 +277,7 @@ class AppModule {
enum Modules {
timetable,
ticker,
talk,
files,
marianumMessage,
@@ -6,7 +6,6 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../storage/settings.dart';
import '../../../../../utils/debouncer.dart';
import '../../../../../view/pages/settings/data/default_settings.dart';
import '../../app_modules.dart';
class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist';
@@ -47,16 +46,17 @@ class SettingsCubit extends HydratedCubit<Settings> {
emit(DefaultSettings.get());
}
// Modules missing from a stale persisted moduleOrder are handled at read
// time by AppModule.effectiveModuleOrder (inserted at their default
// position) — no healing on hydration needed.
@override
Settings fromJson(Map<String, dynamic> json) {
try {
return _appendNewModules(Settings.fromJson(json));
return Settings.fromJson(json);
} catch (_) {
try {
return _appendNewModules(
Settings.fromJson(
return Settings.fromJson(
_mergeSettings(json, DefaultSettings.get().toJson()),
),
);
} catch (_) {
return DefaultSettings.get();
@@ -64,20 +64,6 @@ class SettingsCubit extends HydratedCubit<Settings> {
}
}
// Modules added in newer app versions won't appear in a previously persisted
// moduleOrder. Append any enum value that is neither ordered nor hidden so it
// becomes visible in the "Mehr" menu without forcing a full settings reset.
Settings _appendNewModules(Settings s) {
final order = s.modulesSettings.moduleOrder;
final hidden = s.modulesSettings.hiddenModules;
final missing = Modules.values.where(
(m) => !order.contains(m) && !hidden.contains(m),
);
if (missing.isEmpty) return s;
s.modulesSettings.moduleOrder = [...order, ...missing];
return s;
}
@override
Map<String, dynamic>? toJson(Settings state) => state.toJson();
@@ -0,0 +1,31 @@
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/ticker_repository.dart';
import 'ticker_event.dart';
import 'ticker_state.dart';
class TickerBloc
extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> {
@override
Future<void> gatherData() async {
final results = await Future.wait([repo.getTicker(), repo.getNav()]);
final ticker = results[0] as TickerResponse;
final nav = results[1] as TickerNavResponse;
add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav)));
}
@override
TickerRepository repository() => TickerRepository();
@override
TickerState fromNothing() => const TickerState();
@override
TickerState fromStorage(Map<String, dynamic> json) =>
TickerState.fromJson(json);
@override
Map<String, dynamic>? toStorage(TickerState state) => state.toJson();
}
@@ -0,0 +1,6 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'ticker_state.dart';
sealed class TickerEvent extends LoadableHydratedBlocEvent<TickerState> {}
class TickerLoadEvent extends TickerEvent {}
@@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
part 'ticker_state.freezed.dart';
part 'ticker_state.g.dart';
/// Hydrated ticker module state: the current "Aktuelles" post plus the filtered
/// page tree. Page contents are loaded on demand in the detail screen and are
/// deliberately not cached here.
@freezed
abstract class TickerState with _$TickerState {
const factory TickerState({TickerResponse? ticker, TickerNavResponse? nav}) =
_TickerState;
factory TickerState.fromJson(Map<String, dynamic> json) =>
_$TickerStateFromJson(json);
}
@@ -0,0 +1,280 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'ticker_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$TickerState {
TickerResponse? get ticker; TickerNavResponse? get nav;
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$TickerStateCopyWith<TickerState> get copyWith => _$TickerStateCopyWithImpl<TickerState>(this as TickerState, _$identity);
/// Serializes this TickerState to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is TickerState&&(identical(other.ticker, ticker) || other.ticker == ticker)&&(identical(other.nav, nav) || other.nav == nav));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,ticker,nav);
@override
String toString() {
return 'TickerState(ticker: $ticker, nav: $nav)';
}
}
/// @nodoc
abstract mixin class $TickerStateCopyWith<$Res> {
factory $TickerStateCopyWith(TickerState value, $Res Function(TickerState) _then) = _$TickerStateCopyWithImpl;
@useResult
$Res call({
TickerResponse? ticker, TickerNavResponse? nav
});
}
/// @nodoc
class _$TickerStateCopyWithImpl<$Res>
implements $TickerStateCopyWith<$Res> {
_$TickerStateCopyWithImpl(this._self, this._then);
final TickerState _self;
final $Res Function(TickerState) _then;
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? ticker = freezed,Object? nav = freezed,}) {
return _then(_self.copyWith(
ticker: freezed == ticker ? _self.ticker : ticker // ignore: cast_nullable_to_non_nullable
as TickerResponse?,nav: freezed == nav ? _self.nav : nav // ignore: cast_nullable_to_non_nullable
as TickerNavResponse?,
));
}
}
/// Adds pattern-matching-related methods to [TickerState].
extension TickerStatePatterns on TickerState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _TickerState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _TickerState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _TickerState value) $default,){
final _that = this;
switch (_that) {
case _TickerState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _TickerState value)? $default,){
final _that = this;
switch (_that) {
case _TickerState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( TickerResponse? ticker, TickerNavResponse? nav)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _TickerState() when $default != null:
return $default(_that.ticker,_that.nav);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( TickerResponse? ticker, TickerNavResponse? nav) $default,) {final _that = this;
switch (_that) {
case _TickerState():
return $default(_that.ticker,_that.nav);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( TickerResponse? ticker, TickerNavResponse? nav)? $default,) {final _that = this;
switch (_that) {
case _TickerState() when $default != null:
return $default(_that.ticker,_that.nav);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _TickerState implements TickerState {
const _TickerState({this.ticker, this.nav});
factory _TickerState.fromJson(Map<String, dynamic> json) => _$TickerStateFromJson(json);
@override final TickerResponse? ticker;
@override final TickerNavResponse? nav;
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$TickerStateCopyWith<_TickerState> get copyWith => __$TickerStateCopyWithImpl<_TickerState>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$TickerStateToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _TickerState&&(identical(other.ticker, ticker) || other.ticker == ticker)&&(identical(other.nav, nav) || other.nav == nav));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,ticker,nav);
@override
String toString() {
return 'TickerState(ticker: $ticker, nav: $nav)';
}
}
/// @nodoc
abstract mixin class _$TickerStateCopyWith<$Res> implements $TickerStateCopyWith<$Res> {
factory _$TickerStateCopyWith(_TickerState value, $Res Function(_TickerState) _then) = __$TickerStateCopyWithImpl;
@override @useResult
$Res call({
TickerResponse? ticker, TickerNavResponse? nav
});
}
/// @nodoc
class __$TickerStateCopyWithImpl<$Res>
implements _$TickerStateCopyWith<$Res> {
__$TickerStateCopyWithImpl(this._self, this._then);
final _TickerState _self;
final $Res Function(_TickerState) _then;
/// Create a copy of TickerState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? ticker = freezed,Object? nav = freezed,}) {
return _then(_TickerState(
ticker: freezed == ticker ? _self.ticker : ticker // ignore: cast_nullable_to_non_nullable
as TickerResponse?,nav: freezed == nav ? _self.nav : nav // ignore: cast_nullable_to_non_nullable
as TickerNavResponse?,
));
}
}
// dart format on
@@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'ticker_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_TickerState _$TickerStateFromJson(Map<String, dynamic> json) => _TickerState(
ticker: json['ticker'] == null
? null
: TickerResponse.fromJson(json['ticker'] as Map<String, dynamic>),
nav: json['nav'] == null
? null
: TickerNavResponse.fromJson(json['nav'] as Map<String, dynamic>),
);
Map<String, dynamic> _$TickerStateToJson(_TickerState instance) =>
<String, dynamic>{'ticker': instance.ticker, 'nav': instance.nav};
@@ -0,0 +1,35 @@
import 'dart:typed_data';
import '../../../../../api/demo/data/demo_ticker.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker.dart';
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/ticker_state.dart';
class TickerRepository extends Repository<TickerState> {
Future<TickerResponse> getTicker() {
if (DemoMode.active) return Future.value(DemoTicker.ticker());
return GetTicker().run();
}
Future<TickerNavResponse> getNav() {
if (DemoMode.active) return Future.value(DemoTicker.nav());
return GetTickerNav().run();
}
Future<TickerPageResponse> getPage(String slug) {
if (DemoMode.active) return Future.value(DemoTicker.page(slug));
return GetTickerPage(slug).run();
}
Future<Uint8List> getPageFile(String slug) {
if (DemoMode.active) return Future.value(Uint8List(0));
return GetTickerPageFile(slug).run();
}
}
@@ -15,10 +15,9 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$TimetableState {
Map<String, TimetableGetWeekResponse> get weekCache; TimetableGetRoomsResponse? get rooms; TimetableGetSubjectsResponse? get subjects; TimetableGetHolidaysResponse? get schoolHolidays; TimetableGetSchoolyearResponse? get schoolyear; TimetableGetTimegridResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion;// Boundaries learned from past server denials of inaccessible weeks.
// Inclusive: weeks whose start is on/before `accessibleEndDate` and
// whose end is on/after `accessibleStartDate` are within the user's
// permitted range. Null = no upper / lower bound discovered yet.
Map<String, TimetableGetWeekResponse> get weekCache; TimetableGetRoomsResponse? get rooms; TimetableGetSubjectsResponse? get subjects; TimetableGetHolidaysResponse? get schoolHolidays; TimetableGetSchoolyearResponse? get schoolyear; TimetableGetTimegridResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion;// Boundaries learned from past server denials. A week is permitted when its
// start is on/before `accessibleEndDate` and its end on/after
// `accessibleStartDate`. Null = that bound not discovered yet.
DateTime? get accessibleStartDate; DateTime? get accessibleEndDate;
/// Create a copy of TimetableState
/// with the given fields replaced by the non-null parameter values.
@@ -243,10 +242,9 @@ class _TimetableState extends TimetableState {
@override final DateTime startDate;
@override final DateTime endDate;
@override@JsonKey() final int dataVersion;
// Boundaries learned from past server denials of inaccessible weeks.
// Inclusive: weeks whose start is on/before `accessibleEndDate` and
// whose end is on/after `accessibleStartDate` are within the user's
// permitted range. Null = no upper / lower bound discovered yet.
// Boundaries learned from past server denials. A week is permitted when its
// start is on/before `accessibleEndDate` and its end on/after
// `accessibleStartDate`. Null = that bound not discovered yet.
@override final DateTime? accessibleStartDate;
@override final DateTime? accessibleEndDate;
+1
View File
@@ -31,6 +31,7 @@ Map<String, dynamic> _$ModulesSettingsToJson(
const _$ModulesEnumMap = {
Modules.timetable: 'timetable',
Modules.ticker: 'ticker',
Modules.talk: 'talk',
Modules.files: 'files',
Modules.marianumMessage: 'marianumMessage',
@@ -25,6 +25,7 @@ class DefaultSettings {
modulesSettings: ModulesSettings(
moduleOrder: [
Modules.timetable,
Modules.ticker,
Modules.talk,
Modules.files,
Modules.marianumMessage,
@@ -118,10 +118,19 @@ class ModuleSortBody extends StatelessWidget {
.values
.toList(),
onReorderItem: (oldIndex, newIndex) {
var order = settings.val().modulesSettings.moduleOrder.toList();
final movedModule = order.removeAt(oldIndex);
order.insert(newIndex, movedModule);
settings.val(write: true).modulesSettings.moduleOrder = order;
final displayed = AppModule.modules(
context,
showFiltered: true,
).keys.toList();
settings.val(write: true).modulesSettings.moduleOrder =
AppModule.reorderModuleOrder(
displayed: displayed,
effective: AppModule.effectiveModuleOrder(
settings.val().modulesSettings,
),
oldIndex: oldIndex,
newIndex: newIndex,
);
},
);
},
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import 'widgets/ticker_page_body.dart';
/// Standalone detail screen for a single ticker page, used for deep links from
/// outside the ticker module ([AppRoutes.openTickerPage]). Inside the ticker
/// module itself pages render in-place via [TickerPageBody]. Internal content
/// links keep the classic push behaviour here through [AppRoutes.openTickerLink].
class TickerPageView extends StatelessWidget {
final String slug;
final String? title;
const TickerPageView({super.key, required this.slug, this.title});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(title ?? 'Ticker')),
body: TickerPageBody(
slug: slug,
onLinkTap: (href) => AppRoutes.openTickerLink(context, href),
onRedirect: () => Navigator.of(context).maybePop(),
),
);
}
+351
View File
@@ -0,0 +1,351 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/ticker/bloc/ticker_bloc.dart';
import '../../../state/app/modules/ticker/bloc/ticker_state.dart';
import '../../../theming/app_theme.dart';
import '../../../widget/placeholder_view.dart';
import '../../../widget/prosemirror/pm_json_view.dart';
import 'widgets/ticker_content_card.dart';
import 'widgets/ticker_nav_list.dart';
import 'widgets/ticker_page_body.dart';
/// Ticker module entry. Wires the [TickerBloc] to the presentation
/// [TickerScaffold]; the "Aktuelles" home surface is driven through the loadable
/// consumer (loading/error/pull-to-refresh), individual pages load themselves.
class TickerView extends StatelessWidget {
const TickerView({super.key});
@override
Widget build(BuildContext context) =>
BlocModule<TickerBloc, LoadableState<TickerState>>(
create: (context) => TickerBloc(),
child: (context, bloc, _) {
final sections =
context
.watch<TickerBloc>()
.state
.data
?.nav
?.sections
.where((section) => section.pages.isNotEmpty)
.toList() ??
const <TickerNavSection>[];
return TickerScaffold(
sections: sections,
homeBuilder: (context, onLinkTap) =>
LoadableStateConsumer<TickerBloc, TickerState>(
child: (state, loading) => _TickerHome(
ticker: state.ticker,
hasSections: sections.isNotEmpty,
onLinkTap: onLinkTap,
),
),
);
},
);
}
/// Bloc-free presentation shell: owns the in-place selection state, renders the
/// phone drawer / tablet sidebar navigation, the app bar (with home action) and
/// swaps the content region between the home surface and a single page.
class TickerScaffold extends StatefulWidget {
final List<TickerNavSection> sections;
/// Builds the "Aktuelles" home surface. Receives the in-place link handler so
/// internal ticker links switch the selection instead of pushing.
final Widget Function(
BuildContext context,
void Function(String href) onLinkTap,
)
homeBuilder;
/// Test seam: overrides the per-page content (default renders
/// [TickerPageBody]).
final Widget Function(
BuildContext context,
String slug,
void Function(String href) onLinkTap,
)?
pageBuilder;
const TickerScaffold({
super.key,
required this.sections,
required this.homeBuilder,
this.pageBuilder,
});
static const double sidebarBreakpoint = 900;
static const double sidebarWidth = 300;
@override
State<TickerScaffold> createState() => _TickerScaffoldState();
}
class _TickerScaffoldState extends State<TickerScaffold> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
String? _selectedSlug;
String? _selectedTitle;
// Back-gesture handling needs two pieces to cooperate with
// PersistentTabView: a PopScope (canPop=false on a sub-page) emits the
// NavigationNotification that makes the tab shell intercept the system back
// instead of popping the root route, and this LocalHistoryEntry is what the
// shell then finds poppable on the tab navigator (same mechanism a Drawer
// uses) — popping it returns to "Aktuelles". Either piece alone fails: the
// entry emits no notification (back closes the app), the scope alone leaves
// the tab navigator unpoppable (back switches tabs).
LocalHistoryEntry? _backEntry;
bool _disposing = false;
@override
void dispose() {
_disposing = true;
_removeBackEntry();
super.dispose();
}
void _ensureBackEntry() {
if (_backEntry != null) return;
final route = ModalRoute.of(context);
if (route == null) return;
final entry = LocalHistoryEntry(
onRemove: () {
_backEntry = null;
if (_disposing || !mounted) return;
_clearSelection();
},
);
_backEntry = entry;
route.addLocalHistoryEntry(entry);
}
void _removeBackEntry() {
final entry = _backEntry;
_backEntry = null;
entry?.remove();
}
void _clearSelection() {
if (_selectedSlug == null) return;
setState(() {
_selectedSlug = null;
_selectedTitle = null;
});
}
void _closeDrawerIfOpen() {
final scaffold = _scaffoldKey.currentState;
if (scaffold != null && scaffold.isDrawerOpen) scaffold.closeDrawer();
}
void _selectHome() {
_clearSelection();
_removeBackEntry();
_closeDrawerIfOpen();
}
void _selectPage(TickerNavPage page) {
setState(() {
_selectedSlug = page.slug;
_selectedTitle = page.title;
});
_ensureBackEntry();
_closeDrawerIfOpen();
}
void _openRedirect(TickerNavPage page) {
final url = page.externalUrl;
if (url != null && url.isNotEmpty) {
unawaited(AppRoutes.openExternalUrl(url));
}
_closeDrawerIfOpen();
}
void _onLinkTap(String href) {
final slug = AppRoutes.tickerSlugOf(href);
if (slug != null && slug.isNotEmpty) {
setState(() {
_selectedSlug = slug;
_selectedTitle = _titleForSlug(slug);
});
_ensureBackEntry();
_closeDrawerIfOpen();
return;
}
unawaited(AppRoutes.openExternalUrl(href));
}
String? _titleForSlug(String slug) {
for (final section in widget.sections) {
for (final page in section.pages) {
if (page.slug == slug) return page.title;
}
}
return null;
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= TickerScaffold.sidebarBreakpoint;
final slug = _selectedSlug;
final nav = TickerNavList(
sections: widget.sections,
selectedSlug: slug,
onSelectHome: _selectHome,
onSelectPage: _selectPage,
onRedirect: _openRedirect,
);
// Keep the "Aktuelles" surface permanently mounted (offstage while a
// page is open) so returning to it is instant — a re-mount would re-run
// PmJsonView's image precache and flash its spinner for a frame.
final content = IndexedStack(
index: slug == null ? 0 : 1,
sizing: StackFit.expand,
children: [
widget.homeBuilder(context, _onLinkTap),
if (slug == null)
const SizedBox.shrink()
else
(widget.pageBuilder ?? _defaultPage)(context, slug, _onLinkTap),
],
);
return PopScope(
canPop: slug == null,
// Normally the pop lands on the LocalHistoryEntry, not here; this only
// fires if the entry is missing (no enclosing route) as a fallback.
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _selectHome();
},
child: Scaffold(
key: _scaffoldKey,
appBar: AppBar(
title: Text(slug == null ? 'Ticker' : (_selectedTitle ?? 'Ticker')),
actions: [
if (slug != null)
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: 'Aktuelles',
onPressed: _selectHome,
),
],
),
drawer: wide ? null : Drawer(child: SafeArea(child: nav)),
body: wide
? Row(
children: [
SizedBox(width: TickerScaffold.sidebarWidth, child: nav),
const VerticalDivider(width: 1),
Expanded(child: content),
],
)
: content,
),
);
},
);
Widget _defaultPage(
BuildContext context,
String slug,
void Function(String href) onLinkTap,
) => TickerPageBody(
key: ValueKey(slug),
slug: slug,
onLinkTap: onLinkTap,
onRedirect: _selectHome,
);
}
/// The "Aktuelles" home surface: the current ticker post, or a hint when there
/// is no post (yet). Scrollable so the loadable consumer's pull-to-refresh
/// works.
class _TickerHome extends StatelessWidget {
final TickerResponse? ticker;
final bool hasSections;
final void Function(String href) onLinkTap;
const _TickerHome({
required this.ticker,
required this.hasSections,
required this.onLinkTap,
});
@override
Widget build(BuildContext context) {
final ticker = this.ticker;
if (ticker == null) {
return PlaceholderView(
icon: hasSections ? Icons.campaign_outlined : Icons.feed_outlined,
text: hasSections
? 'Zurzeit gibt es keine aktuelle Meldung.'
: 'Zurzeit sind keine Ticker-Inhalte verfügbar.',
);
}
return ListView(
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
children: [_CurrentTickerCard(ticker: ticker, onLinkTap: onLinkTap)],
);
}
}
class _CurrentTickerCard extends StatelessWidget {
final TickerResponse ticker;
final void Function(String href) onLinkTap;
const _CurrentTickerCard({required this.ticker, required this.onLinkTap});
@override
Widget build(BuildContext context) {
final content = ticker.content;
final hasContent = ticker.available && content != null;
return TickerContentCard(
child: hasContent
? PmJsonView(json: content, onLinkTap: onLinkTap)
: _UnavailableHint(webUrl: ticker.webUrl),
);
}
}
class _UnavailableHint extends StatelessWidget {
final String webUrl;
const _UnavailableHint({required this.webUrl});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Diese Meldung ist in der App nicht verfügbar.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: AppSpacing.xs),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => AppRoutes.openWebUrl(webUrl),
icon: const Icon(Icons.open_in_new),
label: const Text('Im Browser öffnen'),
),
),
],
);
}
@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import '../../../../theming/app_theme.dart';
/// Shared surface for rendered ticker content so the "Aktuelles" home and the
/// sub-pages get identical margin, padding and background tint.
class TickerContentCard extends StatelessWidget {
final Widget child;
const TickerContentCard({super.key, required this.child});
@override
Widget build(BuildContext context) => Card(
margin: const EdgeInsets.fromLTRB(
AppSpacing.sm,
AppSpacing.sm,
AppSpacing.sm,
0,
),
child: Padding(padding: const EdgeInsets.all(AppSpacing.md), child: child),
);
}
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import '../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../../theming/app_theme.dart';
/// Shared navigation list for the ticker module, used both as the phone drawer
/// and as the permanent tablet sidebar. Lists the "Aktuelles" home entry
/// followed by the section-grouped pages. [selectedSlug] is null while the home
/// surface is shown.
class TickerNavList extends StatelessWidget {
final List<TickerNavSection> sections;
final String? selectedSlug;
final VoidCallback onSelectHome;
final void Function(TickerNavPage page) onSelectPage;
final void Function(TickerNavPage page) onRedirect;
const TickerNavList({
super.key,
required this.sections,
required this.selectedSlug,
required this.onSelectHome,
required this.onSelectPage,
required this.onRedirect,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final children = <Widget>[
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.md,
AppSpacing.sm,
),
child: Text('Ticker', style: theme.textTheme.titleLarge),
),
ListTile(
leading: const Icon(Icons.campaign_outlined),
title: const Text('Aktuelles'),
selected: selectedSlug == null,
selectedTileColor: theme.colorScheme.secondaryContainer,
onTap: onSelectHome,
),
for (final section in sections) ..._section(context, section),
];
return ListView(padding: EdgeInsets.zero, children: children);
}
List<Widget> _section(BuildContext context, TickerNavSection section) {
final theme = Theme.of(context);
return [
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.md,
AppSpacing.md,
AppSpacing.md,
AppSpacing.xs,
),
child: Text(
section.title,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
),
),
for (final page in section.pages)
ListTile(
leading: Icon(_iconFor(page.kind)),
title: Text(page.title, overflow: TextOverflow.ellipsis),
trailing: page.kind == TickerPageKind.redirect
? const Icon(Icons.open_in_new)
: null,
selected: page.slug == selectedSlug,
selectedTileColor: theme.colorScheme.secondaryContainer,
onTap: () => page.kind == TickerPageKind.redirect
? onRedirect(page)
: onSelectPage(page),
),
];
}
IconData _iconFor(String kind) {
switch (kind) {
case TickerPageKind.redirect:
return Icons.link;
case TickerPageKind.proxiedFile:
return Icons.picture_as_pdf_outlined;
default:
return Icons.article_outlined;
}
}
}
@@ -0,0 +1,184 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../../../../api/errors/error_mapper.dart';
import '../../../../api/errors/ticker_content_unavailable_exception.dart';
import '../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/ticker/repository/ticker_repository.dart';
import '../../../../theming/app_theme.dart';
import '../../../../widget/placeholder_view.dart';
import '../../../../widget/prosemirror/pm_json_view.dart';
import 'ticker_content_card.dart';
/// Embeddable renderer for a single ticker page. Loads the page on demand and
/// renders it by kind: CONTENT via the native ProseMirror renderer,
/// PROXIED_FILE as a PDF. It carries no Scaffold/AppBar so it can live both
/// in-place inside [TickerView] and inside the standalone `TickerPageView`
/// (deep links from outside the ticker module).
///
/// Reaching a REDIRECT here (e.g. via an internal content link whose slug turns
/// out to be a redirect) opens the browser and, if given, invokes [onRedirect]
/// so the host can leave this page.
class TickerPageBody extends StatefulWidget {
final String slug;
final void Function(String href) onLinkTap;
final VoidCallback? onRedirect;
const TickerPageBody({
super.key,
required this.slug,
required this.onLinkTap,
this.onRedirect,
});
@override
State<TickerPageBody> createState() => _TickerPageBodyState();
}
class _TickerPageBodyState extends State<TickerPageBody> {
final TickerRepository _repo = TickerRepository();
late Future<TickerPageResponse> _future;
@override
void initState() {
super.initState();
_future = _repo.getPage(widget.slug);
}
void _reload() {
setState(() => _future = _repo.getPage(widget.slug));
}
@override
Widget build(BuildContext context) => FutureBuilder<TickerPageResponse>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final error = snapshot.error;
if (error != null) return _buildError(context, error);
return _buildContent(context, snapshot.data!);
},
);
Widget _buildError(BuildContext context, Object error) {
if (error is TickerContentUnavailableException) {
return PlaceholderView(
icon: Icons.public_off_outlined,
text: error.userMessage,
button: error.webUrl == null
? null
: ElevatedButton.icon(
onPressed: () => AppRoutes.openWebUrl(error.webUrl!),
icon: const Icon(Icons.open_in_new),
label: const Text('Im Browser öffnen'),
),
);
}
return PlaceholderView(
icon: Icons.error_outline,
text: errorToUserMessage(error),
button: errorAllowsRetry(error)
? ElevatedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
)
: null,
);
}
Widget _buildContent(BuildContext context, TickerPageResponse page) {
switch (page.kind) {
case TickerPageKind.redirect:
final url = page.externalUrl;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (url != null && url.isNotEmpty) AppRoutes.openExternalUrl(url);
widget.onRedirect?.call();
});
return const Center(child: CircularProgressIndicator());
case TickerPageKind.proxiedFile:
return _ProxiedFileView(repo: _repo, slug: widget.slug);
default:
final content = page.content;
if (content == null) {
return PlaceholderView(
icon: Icons.public_off_outlined,
text: 'Dieser Inhalt ist in der App nicht verfügbar.',
button: page.webUrl == null
? null
: ElevatedButton.icon(
onPressed: () => AppRoutes.openWebUrl(page.webUrl!),
icon: const Icon(Icons.open_in_new),
label: const Text('Im Browser öffnen'),
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: AppSpacing.lg),
child: TickerContentCard(
child: PmJsonView(json: content, onLinkTap: widget.onLinkTap),
),
);
}
}
}
class _ProxiedFileView extends StatefulWidget {
final TickerRepository repo;
final String slug;
const _ProxiedFileView({required this.repo, required this.slug});
@override
State<_ProxiedFileView> createState() => _ProxiedFileViewState();
}
class _ProxiedFileViewState extends State<_ProxiedFileView> {
late Future<Uint8List> _bytes;
@override
void initState() {
super.initState();
_bytes = widget.repo.getPageFile(widget.slug);
}
@override
Widget build(BuildContext context) => FutureBuilder<Uint8List>(
future: _bytes,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final error = snapshot.error;
if (error != null) {
return PlaceholderView(
icon: Icons.error_outline,
text: errorToUserMessage(error),
button: errorAllowsRetry(error)
? ElevatedButton.icon(
onPressed: () => setState(
() => _bytes = widget.repo.getPageFile(widget.slug),
),
icon: const Icon(Icons.refresh),
label: const Text('Erneut versuchen'),
)
: null,
);
}
final bytes = snapshot.data!;
if (bytes.isEmpty) {
return const PlaceholderView(
icon: Icons.picture_as_pdf_outlined,
text: 'Das Dokument konnte nicht geladen werden.',
);
}
return SfPdfViewer.memory(bytes);
},
);
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import '../../theming/app_theme.dart';
import 'pm_document_view.dart';
import 'pm_node.dart';
/// Accent color + leading icon per callout variant, mirroring the web renderer.
class _CalloutStyle {
final Color accent;
final IconData icon;
const _CalloutStyle(this.accent, this.icon);
}
const _calloutStyles = <PmCalloutVariant, _CalloutStyle>{
PmCalloutVariant.info: _CalloutStyle(Color(0xFF2563EB), Icons.info_outline),
PmCalloutVariant.tip: _CalloutStyle(
Color(0xFFA16207),
Icons.lightbulb_outline,
),
PmCalloutVariant.success: _CalloutStyle(
Color(0xFF16A34A),
Icons.check_circle_outline,
),
PmCalloutVariant.warning: _CalloutStyle(
Color(0xFFEA580C),
Icons.warning_amber_outlined,
),
PmCalloutVariant.important: _CalloutStyle(
Color(0xFFB91C1C),
Icons.priority_high,
),
};
class PmCalloutView extends StatelessWidget {
final PmCallout node;
const PmCalloutView({required this.node, super.key});
@override
Widget build(BuildContext context) {
final style = _calloutStyles[node.variant] ?? _calloutStyles.values.first;
return DecoratedBox(
decoration: BoxDecoration(
color: style.accent.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(8),
border: Border(left: BorderSide(color: style.accent, width: 4)),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(style.icon, color: style.accent, size: 20),
const SizedBox(width: AppSpacing.sm),
Expanded(child: pmBlocks(node.children)),
],
),
),
);
}
}
@@ -0,0 +1,251 @@
import 'package:flutter/material.dart';
import '../../theming/app_theme.dart';
import 'pm_callout_view.dart';
import 'pm_image_view.dart';
import 'pm_node.dart';
import 'pm_render_scope.dart';
import 'pm_rich_text.dart';
import 'pm_table_view.dart';
/// Read-only renderer for a parsed ProseMirror document.
///
/// Content is centered and width-capped for comfortable line lengths on
/// tablets. All styling derives from `Theme.of(context)`; the only hard-coded
/// colors are the callout accents.
class PmDocumentView extends StatelessWidget {
final PmNode doc;
final void Function(String href)? onLinkTap;
final double maxContentWidth;
const PmDocumentView({
required this.doc,
this.onLinkTap,
this.maxContentWidth = 720,
super.key,
});
@override
Widget build(BuildContext context) {
return PmRenderScope(
onLinkTap: onLinkTap,
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxContentWidth),
child: PmNodeView(node: doc),
),
),
);
}
}
typedef PmNodeBuilder = Widget Function(PmNode node);
/// Dispatches a node to its registered widget, falling back to
/// [FallbackNodeWidget] for anything unregistered (including [PmUnknown]).
class PmNodeView extends StatelessWidget {
final PmNode node;
const PmNodeView({required this.node, super.key});
static final Map<Type, PmNodeBuilder> registry = {
PmParagraph: (n) => PmParagraphView(node: n as PmParagraph),
PmHeading: (n) => PmHeadingView(node: n as PmHeading),
PmBulletList: (n) => PmBulletListView(node: n as PmBulletList),
PmOrderedList: (n) => PmOrderedListView(node: n as PmOrderedList),
PmBlockquote: (n) => PmBlockquoteView(node: n as PmBlockquote),
PmCodeBlock: (n) => PmCodeBlockView(node: n as PmCodeBlock),
PmHorizontalRule: (n) => const PmHorizontalRuleView(),
PmCallout: (n) => PmCalloutView(node: n as PmCallout),
PmImage: (n) => PmImageView(node: n as PmImage),
PmTable: (n) => PmTableView(node: n as PmTable),
};
@override
Widget build(BuildContext context) {
final builder = registry[node.runtimeType];
if (builder != null) return builder(node);
return FallbackNodeWidget(node: node);
}
}
/// Renders a node's children as a vertical block stack. Used for the document
/// root, unknown nodes, and any container whose type has no dedicated widget.
class FallbackNodeWidget extends StatelessWidget {
final PmNode node;
const FallbackNodeWidget({required this.node, super.key});
@override
Widget build(BuildContext context) {
if (node.children.isEmpty) return const SizedBox.shrink();
return pmBlocks(node.children);
}
}
/// Lays out block-level nodes in a column, evenly spaced.
Widget pmBlocks(List<PmNode> nodes, {double gap = AppSpacing.sm}) {
final children = <Widget>[];
for (var i = 0; i < nodes.length; i++) {
if (i > 0) children.add(SizedBox(height: gap));
children.add(PmNodeView(node: nodes[i]));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: children,
);
}
class PmParagraphView extends StatelessWidget {
final PmParagraph node;
const PmParagraphView({required this.node, super.key});
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
return PmRichText(
inlines: node.children,
baseStyle: base,
textAlign: node.align ?? TextAlign.start,
);
}
}
class PmHeadingView extends StatelessWidget {
final PmHeading node;
const PmHeadingView({required this.node, super.key});
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final base = switch (node.level) {
1 => textTheme.headlineSmall,
2 => textTheme.titleLarge,
3 => textTheme.titleMedium,
_ => textTheme.titleSmall,
};
return PmRichText(
inlines: node.children,
baseStyle: base ?? const TextStyle(),
textAlign: node.align ?? TextAlign.start,
);
}
}
class PmBulletListView extends StatelessWidget {
final PmBulletList node;
const PmBulletListView({required this.node, super.key});
@override
Widget build(BuildContext context) =>
_ListLayout(items: node.children, markerFor: (_) => '');
}
class PmOrderedListView extends StatelessWidget {
final PmOrderedList node;
const PmOrderedListView({required this.node, super.key});
@override
Widget build(BuildContext context) => _ListLayout(
items: node.children,
markerFor: (index) => '${node.start + index}.',
);
}
class _ListLayout extends StatelessWidget {
final List<PmNode> items;
final String Function(int index) markerFor;
const _ListLayout({required this.items, required this.markerFor});
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodyMedium ?? const TextStyle();
final rows = <Widget>[];
for (var i = 0; i < items.length; i++) {
if (i > 0) rows.add(const SizedBox(height: AppSpacing.xs));
rows.add(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 24,
child: Text(
markerFor(i),
textAlign: TextAlign.right,
style: base,
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(child: pmBlocks(items[i].children, gap: AppSpacing.xs)),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: rows,
);
}
}
class PmBlockquoteView extends StatelessWidget {
final PmBlockquote node;
const PmBlockquoteView({required this.node, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.only(left: AppSpacing.md),
decoration: BoxDecoration(
border: Border(
left: BorderSide(color: theme.colorScheme.outlineVariant, width: 4),
),
),
child: pmBlocks(node.children),
);
}
}
class PmCodeBlockView extends StatelessWidget {
final PmCodeBlock node;
const PmCodeBlockView({required this.node, super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final code = node.children.whereType<PmText>().map((t) => t.text).join();
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.md),
child: Text(
code,
style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
),
),
),
);
}
}
class PmHorizontalRuleView extends StatelessWidget {
const PmHorizontalRuleView({super.key});
@override
Widget build(BuildContext context) => const Divider();
}
+135
View File
@@ -0,0 +1,135 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'pm_node.dart';
import 'pm_render_scope.dart';
class PmImageView extends StatelessWidget {
final PmImage node;
const PmImageView({required this.node, super.key});
@override
Widget build(BuildContext context) {
final provider = _imageProvider();
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final targetWidth = _resolveWidth(node.width, available);
Widget image = ConstrainedBox(
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
child: _imageWidget(context, provider),
);
final href = node.href;
if (href != null && href.isNotEmpty) {
image = InkWell(
onTap: () => PmRenderScope.maybeOf(context)?.onLinkTap?.call(href),
child: image,
);
} else if (provider != null) {
image = InkWell(
onTap: () => _openFullscreen(context, provider),
child: image,
);
}
return Align(alignment: _alignment(node.align), child: image);
},
);
}
ImageProvider? _imageProvider() {
if (node.bytes != null) return MemoryImage(node.bytes!);
if (node.src.startsWith('http')) {
return CachedNetworkImageProvider(node.src);
}
return null;
}
Widget _imageWidget(BuildContext context, ImageProvider? provider) {
if (node.bytes != null) {
return Image.memory(
node.bytes!,
errorBuilder: (context, error, stack) => _brokenImage(context),
);
}
if (node.src.startsWith('http')) {
return CachedNetworkImage(
imageUrl: node.src,
errorWidget: (context, url, error) => _brokenImage(context),
);
}
return _brokenImage(context);
}
Widget _brokenImage(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Icon(
Icons.broken_image_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
void _openFullscreen(BuildContext context, ImageProvider provider) {
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (context) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
),
body: PhotoView(
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
imageProvider: provider,
backgroundDecoration: const BoxDecoration(color: Colors.black),
),
),
);
}
Alignment _alignment(String? align) {
switch (align) {
case 'center':
return Alignment.center;
case 'right':
return Alignment.centerRight;
default:
return Alignment.centerLeft;
}
}
double? _resolveWidth(String? width, double available) {
if (width == null) return null;
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|%|em|rem)$').firstMatch(width);
if (match == null) return null;
final value = double.parse(match.group(1)!);
final double resolved;
switch (match.group(2)) {
case '%':
resolved = available * value / 100;
case 'em':
case 'rem':
resolved = value * 16;
default:
resolved = value;
}
return resolved.clamp(1, available);
}
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'pm_document_view.dart';
import 'pm_node.dart';
/// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds.
/// Parsing decodes base64 images; doing it in build() would re-decode them on
/// every rebuild — call sites should use this instead of PmNode.fromJson.
///
/// Images are precached before a document is presented, so the reader never
/// sees images pop in and text reflow downwards. Until the first document is
/// ready a spinner shows; on updates the previous document stays visible until
/// the new one is fully precached (no spinner flash on background refreshes).
/// A timeout keeps a dead network image from blocking the swap forever (the
/// image then renders with its broken-image fallback).
class PmJsonView extends StatefulWidget {
final Map<String, dynamic> json;
final void Function(String href)? onLinkTap;
const PmJsonView({required this.json, this.onLinkTap, super.key});
static const Duration precacheTimeout = Duration(seconds: 8);
@override
State<PmJsonView> createState() => _PmJsonViewState();
}
class _PmJsonViewState extends State<PmJsonView> {
PmNode? _shown;
PmNode? _pending;
List<ImageProvider> _pendingProviders = const [];
bool _precacheStarted = false;
int _generation = 0;
@override
void initState() {
super.initState();
_parse();
}
@override
void didUpdateWidget(PmJsonView oldWidget) {
super.didUpdateWidget(oldWidget);
if (identical(oldWidget.json, widget.json)) return;
// Background refreshes deliver a new map instance with identical content;
// re-parsing would re-decode every base64 image for a no-op swap.
if (const DeepCollectionEquality().equals(oldWidget.json, widget.json)) {
return;
}
_parse();
_precache();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_precache();
}
void _parse() {
final doc = PmNode.fromJson(widget.json);
final providers = <ImageProvider>[];
_collectProviders(doc, providers);
_generation++;
_precacheStarted = false;
if (providers.isEmpty) {
_shown = doc;
_pending = null;
_pendingProviders = const [];
} else {
_pending = doc;
_pendingProviders = providers;
}
}
void _precache() {
final pending = _pending;
if (pending == null || _precacheStarted) return;
_precacheStarted = true;
final generation = _generation;
Future.wait([
for (final provider in _pendingProviders)
precacheImage(provider, context, onError: (_, _) {}),
])
.timeout(PmJsonView.precacheTimeout, onTimeout: () => const [])
.whenComplete(() {
if (mounted && generation == _generation) {
setState(() {
_shown = pending;
_pending = null;
_pendingProviders = const [];
});
}
});
}
void _collectProviders(PmNode node, List<ImageProvider> out) {
if (node is PmImage) {
final bytes = node.bytes;
if (bytes != null) {
out.add(MemoryImage(bytes));
} else if (node.src.startsWith('http')) {
out.add(CachedNetworkImageProvider(node.src));
}
}
for (final child in node.children) {
_collectProviders(child, out);
}
}
@override
Widget build(BuildContext context) {
final shown = _shown;
if (shown == null) {
return const SizedBox(
height: 160,
child: Center(child: CircularProgressIndicator()),
);
}
return PmDocumentView(doc: shown, onLinkTap: widget.onLinkTap);
}
}
+327
View File
@@ -0,0 +1,327 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
/// Read-only model for a ProseMirror / TipTap document node.
///
/// Parsing is deliberately total: an unknown `type` never throws, it becomes a
/// [PmUnknown] so the renderer can fall back gracefully (schemaVersion drift).
sealed class PmNode {
const PmNode();
/// Block/inline children. Empty for leaf nodes.
List<PmNode> get children => const [];
factory PmNode.fromJson(Map<String, dynamic> json) {
final type = json['type'];
final attrs = _attrs(json['attrs']);
switch (type) {
case 'paragraph':
return PmParagraph(
align: _parseTextAlign(attrs['textAlign']),
children: _parseChildren(json['content']),
);
case 'heading':
return PmHeading(
level: _parseInt(attrs['level'], fallback: 1, min: 1, max: 6),
align: _parseTextAlign(attrs['textAlign']),
children: _parseChildren(json['content']),
);
case 'text':
return PmText(
text: json['text'] is String ? json['text'] as String : '',
marks: _parseMarks(json['marks']),
);
case 'bulletList':
return PmBulletList(children: _parseChildren(json['content']));
case 'orderedList':
return PmOrderedList(
start: _parseInt(attrs['start'], fallback: 1, min: 1),
children: _parseChildren(json['content']),
);
case 'listItem':
return PmListItem(children: _parseChildren(json['content']));
case 'blockquote':
return PmBlockquote(children: _parseChildren(json['content']));
case 'codeBlock':
return PmCodeBlock(
language: attrs['language'] is String
? attrs['language'] as String
: null,
children: _parseChildren(json['content']),
);
case 'horizontalRule':
return const PmHorizontalRule();
case 'hardBreak':
return const PmHardBreak();
case 'image':
return PmImage.fromAttrs(attrs);
case 'callout':
return PmCallout(
variant: PmCalloutVariant.parse(attrs['variant']),
children: _parseChildren(json['content']),
);
case 'table':
return PmTable(children: _parseChildren(json['content']));
case 'tableRow':
return PmTableRow(children: _parseChildren(json['content']));
case 'tableHeader':
case 'tableCell':
return PmTableCell(
header: type == 'tableHeader',
colspan: _parseInt(attrs['colspan'], fallback: 1, min: 1),
rowspan: _parseInt(attrs['rowspan'], fallback: 1, min: 1),
children: _parseChildren(json['content']),
);
default:
return PmUnknown(
rawType: type is String ? type : 'unknown',
children: _parseChildren(json['content']),
);
}
}
static List<PmNode> _parseChildren(dynamic content) {
if (content is! List) return const [];
return content
.whereType<Map<dynamic, dynamic>>()
.map((e) => PmNode.fromJson(e.cast<String, dynamic>()))
.toList(growable: false);
}
static List<PmMark> _parseMarks(dynamic marks) {
if (marks is! List) return const [];
return marks
.whereType<Map<dynamic, dynamic>>()
.map((m) {
final map = m.cast<String, dynamic>();
final type = map['type'];
return PmMark(
type: type is String ? type : '',
attrs: _attrs(map['attrs']),
);
})
.where((m) => m.type.isNotEmpty)
.toList(growable: false);
}
static Map<String, dynamic> _attrs(dynamic attrs) =>
attrs is Map ? attrs.cast<String, dynamic>() : const {};
static TextAlign? _parseTextAlign(dynamic value) {
switch (value) {
case 'left':
return TextAlign.left;
case 'right':
return TextAlign.right;
case 'center':
return TextAlign.center;
case 'justify':
return TextAlign.justify;
default:
return null;
}
}
static int _parseInt(
dynamic value, {
required int fallback,
int? min,
int? max,
}) {
var result = fallback;
if (value is num) result = value.toInt();
if (value is String) result = int.tryParse(value) ?? fallback;
if (min != null && result < min) result = min;
if (max != null && result > max) result = max;
return result;
}
}
/// A formatting mark on a [PmText] run (bold, link, highlight, …). Unknown mark
/// types are kept verbatim and simply ignored by the renderer.
class PmMark {
final String type;
final Map<String, dynamic> attrs;
const PmMark({required this.type, this.attrs = const {}});
}
class PmParagraph extends PmNode {
final TextAlign? align;
@override
final List<PmNode> children;
const PmParagraph({this.align, this.children = const []});
}
class PmHeading extends PmNode {
final int level;
final TextAlign? align;
@override
final List<PmNode> children;
const PmHeading({required this.level, this.align, this.children = const []});
}
class PmText extends PmNode {
final String text;
final List<PmMark> marks;
const PmText({required this.text, this.marks = const []});
}
class PmBulletList extends PmNode {
@override
final List<PmNode> children;
const PmBulletList({this.children = const []});
}
class PmOrderedList extends PmNode {
final int start;
@override
final List<PmNode> children;
const PmOrderedList({this.start = 1, this.children = const []});
}
class PmListItem extends PmNode {
@override
final List<PmNode> children;
const PmListItem({this.children = const []});
}
class PmBlockquote extends PmNode {
@override
final List<PmNode> children;
const PmBlockquote({this.children = const []});
}
class PmCodeBlock extends PmNode {
final String? language;
@override
final List<PmNode> children;
const PmCodeBlock({this.language, this.children = const []});
}
class PmHorizontalRule extends PmNode {
const PmHorizontalRule();
}
class PmHardBreak extends PmNode {
const PmHardBreak();
}
class PmImage extends PmNode {
final String src;
final String? alt;
final String? title;
final String? align;
final String? width;
final String? href;
/// Base64 `data:image/` payloads are decoded exactly once at parse time and
/// memoised here — never re-decoded in `build()`. `null` means either a
/// network source or a decode failure (renderer shows a broken-image icon).
final Uint8List? bytes;
const PmImage({
required this.src,
this.alt,
this.title,
this.align,
this.width,
this.href,
this.bytes,
});
factory PmImage.fromAttrs(Map<String, dynamic> attrs) {
final src = attrs['src'] is String ? attrs['src'] as String : '';
return PmImage(
src: src,
alt: attrs['alt'] is String ? attrs['alt'] as String : null,
title: attrs['title'] is String ? attrs['title'] as String : null,
align: attrs['align'] is String ? attrs['align'] as String : null,
width: attrs['width'] is String ? attrs['width'] as String : null,
href: attrs['href'] is String ? attrs['href'] as String : null,
bytes: _decodeDataImage(src),
);
}
static Uint8List? _decodeDataImage(String src) {
if (!src.startsWith('data:image/')) return null;
final comma = src.indexOf(',');
if (comma < 0) return null;
if (!src.substring(0, comma).contains(';base64')) return null;
try {
return base64Decode(src.substring(comma + 1));
} catch (_) {
return null;
}
}
}
enum PmCalloutVariant {
info,
tip,
success,
warning,
important;
static PmCalloutVariant parse(dynamic value) {
for (final variant in PmCalloutVariant.values) {
if (variant.name == value) return variant;
}
return PmCalloutVariant.info;
}
}
class PmCallout extends PmNode {
final PmCalloutVariant variant;
@override
final List<PmNode> children;
const PmCallout({required this.variant, this.children = const []});
}
class PmTable extends PmNode {
@override
final List<PmNode> children;
const PmTable({this.children = const []});
}
class PmTableRow extends PmNode {
@override
final List<PmNode> children;
const PmTableRow({this.children = const []});
}
class PmTableCell extends PmNode {
final bool header;
final int colspan;
final int rowspan;
@override
final List<PmNode> children;
const PmTableCell({
this.header = false,
this.colspan = 1,
this.rowspan = 1,
this.children = const [],
});
}
class PmUnknown extends PmNode {
final String rawType;
@override
final List<PmNode> children;
const PmUnknown({required this.rawType, this.children = const []});
}
@@ -0,0 +1,22 @@
import 'package:flutter/widgets.dart';
/// Carries render-time collaborators (the link-tap hook) down the node tree so
/// individual node widgets stay pure `(node) => Widget` builders.
class PmRenderScope extends InheritedWidget {
/// Invoked when a link mark or an image `href` is tapped. The navigation /
/// URL policy is injected from outside — the renderer only exposes the hook.
final void Function(String href)? onLinkTap;
const PmRenderScope({
required this.onLinkTap,
required super.child,
super.key,
});
static PmRenderScope? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<PmRenderScope>();
@override
bool updateShouldNotify(PmRenderScope oldWidget) =>
oldWidget.onLinkTap != onLinkTap;
}
+208
View File
@@ -0,0 +1,208 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'pm_node.dart';
import 'pm_render_scope.dart';
/// Renders a run of inline nodes ([PmText] / [PmHardBreak]) as a single
/// `Text.rich`, composing marks into `TextStyle`s.
///
/// Link marks need a [TapGestureRecognizer], which must be disposed to avoid
/// leaks. The recognizers are therefore built in [didChangeDependencies] /
/// [didUpdateWidget] and released in [dispose] — never allocated inside
/// [build].
class PmRichText extends StatefulWidget {
final List<PmNode> inlines;
final TextStyle baseStyle;
final TextAlign textAlign;
const PmRichText({
required this.inlines,
required this.baseStyle,
this.textAlign = TextAlign.start,
super.key,
});
@override
State<PmRichText> createState() => _PmRichTextState();
}
class _PmRichTextState extends State<PmRichText> {
final List<TapGestureRecognizer> _recognizers = [];
InlineSpan _span = const TextSpan();
@override
void didChangeDependencies() {
super.didChangeDependencies();
_rebuild();
}
@override
void didUpdateWidget(PmRichText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.inlines != widget.inlines ||
oldWidget.baseStyle != widget.baseStyle ||
oldWidget.textAlign != widget.textAlign) {
_rebuild();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _rebuild() {
_disposeRecognizers();
final theme = Theme.of(context);
final onLinkTap = PmRenderScope.maybeOf(context)?.onLinkTap;
final spans = <InlineSpan>[];
for (final node in widget.inlines) {
if (node is PmHardBreak) {
spans.add(const TextSpan(text: '\n'));
} else if (node is PmText) {
spans.add(_textSpan(node, theme, onLinkTap));
}
}
_span = TextSpan(style: widget.baseStyle, children: spans);
}
TextSpan _textSpan(
PmText node,
ThemeData theme,
void Function(String href)? onLinkTap,
) {
String? href;
final style = _styleForMarks(
node.marks,
widget.baseStyle,
theme,
onLink: (value) => href = value,
);
TapGestureRecognizer? recognizer;
if (href != null && onLinkTap != null) {
final target = href!;
recognizer = TapGestureRecognizer()..onTap = () => onLinkTap(target);
_recognizers.add(recognizer);
}
return TextSpan(text: node.text, style: style, recognizer: recognizer);
}
TextStyle _styleForMarks(
List<PmMark> marks,
TextStyle base,
ThemeData theme, {
required void Function(String href) onLink,
}) {
var style = base;
final decorations = <TextDecoration>[];
for (final mark in marks) {
switch (mark.type) {
case 'bold':
style = style.copyWith(fontWeight: FontWeight.bold);
case 'italic':
style = style.copyWith(fontStyle: FontStyle.italic);
case 'strike':
decorations.add(TextDecoration.lineThrough);
case 'underline':
decorations.add(TextDecoration.underline);
case 'code':
style = style.copyWith(
fontFamily: 'monospace',
backgroundColor: theme.colorScheme.surfaceContainerHighest,
);
case 'highlight':
style = style.copyWith(
backgroundColor: _highlightColor(mark.attrs['color'], theme),
);
case 'textStyle':
final size = _fontSize(mark.attrs['fontSize'], base.fontSize ?? 14);
if (size != null) style = style.copyWith(fontSize: size);
case 'link':
final rawHref = mark.attrs['href'];
if (rawHref is String && rawHref.isNotEmpty) {
onLink(rawHref);
style = style.copyWith(color: theme.colorScheme.primary);
decorations.add(TextDecoration.underline);
}
}
}
if (decorations.isNotEmpty) {
style = style.copyWith(decoration: TextDecoration.combine(decorations));
}
return style;
}
Color _highlightColor(dynamic color, ThemeData theme) {
final parsed = color is String ? _parseCssColor(color) : null;
final base = parsed ?? theme.colorScheme.tertiaryContainer;
// Full-opacity highlights swamp the text in dark mode, so dial the alpha
// down further there while keeping the accent readable in light mode.
final dark = theme.brightness == Brightness.dark;
return base.withValues(alpha: dark ? 0.30 : 0.45);
}
double? _fontSize(dynamic raw, double base) {
if (raw is! String) return null;
final match = RegExp(r'^(\d+(?:\.\d+)?)(px|rem|em|%)$').firstMatch(raw);
if (match == null) return null;
final value = double.parse(match.group(1)!);
final double size;
switch (match.group(2)) {
case 'px':
size = value;
case 'rem':
size = value * 16;
case 'em':
size = base * value;
case '%':
size = base * value / 100;
default:
return null;
}
return size.clamp(8, 72);
}
@override
Widget build(BuildContext context) =>
Text.rich(_span, textAlign: widget.textAlign);
}
/// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors.
/// Returns `null` for anything unrecognised so the caller can fall back.
Color? _parseCssColor(String raw) {
final value = raw.trim().toLowerCase();
if (value.startsWith('#')) {
var hex = value.substring(1);
if (hex.length == 3) {
hex = hex.split('').map((c) => '$c$c').join();
}
if (hex.length == 6) hex = 'ff$hex';
if (hex.length == 8) {
final rgba = int.tryParse(hex, radix: 16);
if (rgba != null) return Color(rgba);
}
return null;
}
return _namedColors[value];
}
const _namedColors = <String, Color>{
'red': Color(0xFFEF4444),
'orange': Color(0xFFF97316),
'yellow': Color(0xFFFACC15),
'green': Color(0xFF22C55E),
'blue': Color(0xFF3B82F6),
'purple': Color(0xFFA855F7),
'pink': Color(0xFFEC4899),
'gray': Color(0xFF9CA3AF),
'grey': Color(0xFF9CA3AF),
};
+95
View File
@@ -0,0 +1,95 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_layout_grid/flutter_layout_grid.dart';
import '../../theming/app_theme.dart';
import 'pm_document_view.dart';
import 'pm_node.dart';
/// Renders a ProseMirror table. Flutter's built-in `Table` cannot span cells,
/// so `flutter_layout_grid` places each cell explicitly, honouring
/// colspan/rowspan via a simple HTML-style occupancy scan.
class PmTableView extends StatelessWidget {
final PmTable node;
static const double _minColumnWidth = 140;
const PmTableView({required this.node, super.key});
@override
Widget build(BuildContext context) {
final rows = node.children.whereType<PmTableRow>().toList();
if (rows.isEmpty) return const SizedBox.shrink();
final occupied = <int, Set<int>>{};
final placements = <Widget>[];
var columnCount = 0;
for (var r = 0; r < rows.length; r++) {
var col = 0;
for (final cell in rows[r].children.whereType<PmTableCell>()) {
while (occupied[r]?.contains(col) ?? false) {
col++;
}
placements.add(
GridPlacement(
columnStart: col,
columnSpan: cell.colspan,
rowStart: r,
rowSpan: cell.rowspan,
child: _cell(context, cell),
),
);
for (var dr = 0; dr < cell.rowspan; dr++) {
final set = occupied[r + dr] ??= <int>{};
for (var dc = 0; dc < cell.colspan; dc++) {
set.add(col + dc);
}
}
col += cell.colspan;
columnCount = max(columnCount, col);
}
}
if (columnCount == 0) return const SizedBox.shrink();
return LayoutBuilder(
builder: (context, constraints) {
final available = constraints.maxWidth.isFinite
? constraints.maxWidth
: _minColumnWidth * columnCount;
final columnWidth = max(_minColumnWidth, available / columnCount);
final totalWidth = columnWidth * columnCount;
final grid = SizedBox(
width: totalWidth,
child: LayoutGrid(
columnSizes: List.filled(columnCount, fixed(columnWidth)),
rowSizes: List.filled(rows.length, auto),
children: placements,
),
);
if (totalWidth <= available) return grid;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: grid,
);
},
);
}
Widget _cell(BuildContext context, PmTableCell cell) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: cell.header ? theme.colorScheme.surfaceContainerHighest : null,
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.sm),
child: pmBlocks(cell.children, gap: AppSpacing.xs),
),
);
}
}
+1
View File
@@ -94,6 +94,7 @@ dependencies:
# Opens the OS notification settings for this app (iOS + Android) when the
# user declined the permission and wants to enable it later.
app_settings: ^5.1.1
flutter_layout_grid: ^2.0.8
dev_dependencies:
flutter_test:
@@ -0,0 +1,202 @@
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/errors/ticker_content_unavailable_exception.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
void main() {
group('TickerResponse.fromJson', () {
test('available post carries the nested content object', () {
final response = TickerResponse.fromJson({
'schemaVersion': 1,
'available': true,
'hash': 'R123:uuid@2026-07-08T10:00:00',
'publishedAt': '2026-07-08T10:00:00',
'webUrl': '/ticker',
'content': {
'type': 'doc',
'content': [
{
'type': 'paragraph',
'content': [
{'type': 'text', 'text': 'Hallo'},
],
},
],
},
});
expect(response.available, isTrue);
expect(response.schemaVersion, 1);
expect(response.webUrl, '/ticker');
expect(response.content, isA<Map<String, dynamic>>());
expect(response.content!['type'], 'doc');
});
test('unavailable post keeps content null and defaults safely', () {
final response = TickerResponse.fromJson({
'schemaVersion': 1,
'available': false,
'webUrl': '/ticker',
'content': null,
});
expect(response.available, isFalse);
expect(response.content, isNull);
expect(response.hash, isNull);
});
});
group('TickerNavResponse.fromJson', () {
test('parses sections with per-kind fields', () {
final nav = TickerNavResponse.fromJson({
'schemaVersion': 1,
'navHash': 'nav-abc',
'sections': [
{
'title': 'Informationen',
'pages': [
{'title': 'Über uns', 'slug': 'ueber-uns', 'kind': 'CONTENT'},
{
'title': 'Webseite',
'slug': 'web',
'kind': 'REDIRECT',
'externalUrl': 'https://marianum-fulda.de',
'externalUrlNewTab': true,
},
{
'title': 'Flyer',
'slug': 'flyer',
'kind': 'PROXIED_FILE',
'fileUrl': 'ticker/pages/flyer/file',
},
],
},
],
});
expect(nav.navHash, 'nav-abc');
expect(nav.sections, hasLength(1));
final pages = nav.sections.single.pages;
expect(pages.map((p) => p.kind), ['CONTENT', 'REDIRECT', 'PROXIED_FILE']);
expect(pages[1].externalUrl, 'https://marianum-fulda.de');
expect(pages[1].externalUrlNewTab, isTrue);
expect(pages[2].fileUrl, 'ticker/pages/flyer/file');
});
test('missing sections default to empty', () {
final nav = TickerNavResponse.fromJson({
'schemaVersion': 1,
'navHash': '',
});
expect(nav.sections, isEmpty);
});
});
group('TickerPageResponse.fromJson', () {
test('CONTENT page exposes the nested document', () {
final page = TickerPageResponse.fromJson({
'schemaVersion': 1,
'slug': 'ueber-uns',
'title': 'Über uns',
'kind': 'CONTENT',
'content': {'type': 'doc', 'content': []},
});
expect(page.kind, TickerPageKind.content);
expect(page.content, isNotNull);
});
test('REDIRECT page exposes the external URL', () {
final page = TickerPageResponse.fromJson({
'schemaVersion': 1,
'kind': 'REDIRECT',
'externalUrl': 'https://marianum-fulda.de',
});
expect(page.kind, TickerPageKind.redirect);
expect(page.externalUrl, 'https://marianum-fulda.de');
});
test('PROXIED_FILE page exposes file metadata', () {
final page = TickerPageResponse.fromJson({
'schemaVersion': 1,
'kind': 'PROXIED_FILE',
'fileUrl': 'ticker/pages/flyer/file',
'contentType': 'application/pdf',
'filename': 'flyer.pdf',
});
expect(page.kind, TickerPageKind.proxiedFile);
expect(page.contentType, 'application/pdf');
expect(page.filename, 'flyer.pdf');
});
});
group('GetTickerPage error mapping', () {
test('404 CONTENT_UNAVAILABLE maps to a typed exception with webUrl', () async {
final requestOptions = RequestOptions(path: 'ticker/pages/foo');
final dio = _ThrowingDio(
DioException(
requestOptions: requestOptions,
type: DioExceptionType.badResponse,
response: Response<dynamic>(
requestOptions: requestOptions,
statusCode: 404,
data: {'error': 'CONTENT_UNAVAILABLE', 'webUrl': '/ticker/p/foo'},
),
),
);
expect(
() => GetTickerPage('foo', dio: dio).run(),
throwsA(
isA<TickerContentUnavailableException>()
.having((e) => e.webUrl, 'webUrl', '/ticker/p/foo')
.having((e) => e.allowRetry, 'allowRetry', isFalse),
),
);
});
test('other 404 (NOT_FOUND) does not become CONTENT_UNAVAILABLE', () async {
final requestOptions = RequestOptions(path: 'ticker/pages/foo');
final dio = _ThrowingDio(
DioException(
requestOptions: requestOptions,
type: DioExceptionType.badResponse,
response: Response<dynamic>(
requestOptions: requestOptions,
statusCode: 404,
data: {'error': 'NOT_FOUND', 'webUrl': '/ticker/p/foo'},
),
),
);
expect(
() => GetTickerPage('foo', dio: dio).run(),
throwsA(isNot(isA<TickerContentUnavailableException>())),
);
});
});
}
/// Minimal fake Dio whose `get` always fails with the given exception; every
/// other member is unused.
class _ThrowingDio implements Dio {
final DioException error;
_ThrowingDio(this.error);
@override
Future<Response<T>> get<T>(
String path, {
Object? data,
Map<String, dynamic>? queryParameters,
Options? options,
CancelToken? cancelToken,
ProgressCallback? onReceiveProgress,
}) => Future<Response<T>>.error(error);
@override
dynamic noSuchMethod(Invocation invocation) =>
super.noSuchMethod(invocation);
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
import 'package:marianum_mobile/storage/modules_settings.dart';
void main() {
ModulesSettings settingsWith(List<Modules> order) =>
ModulesSettings(moduleOrder: order, hiddenModules: []);
group('effectiveModuleOrder', () {
test('inserts modules missing from a stale persisted order at their '
'default position', () {
// Regression: persisted settings from before the ticker module existed
// repeatedly made new modules vanish from bar, "Mehr" and settings list.
final stale = Modules.values
.where((m) => m != Modules.ticker)
.toList();
final effective = AppModule.effectiveModuleOrder(settingsWith(stale));
expect(effective, Modules.values);
});
test('inserts a missing module after its closest present predecessor', () {
final custom = [
Modules.files,
Modules.timetable,
Modules.talk,
Modules.marianumMessage,
];
final effective = AppModule.effectiveModuleOrder(settingsWith(custom));
expect(
effective.indexOf(Modules.ticker),
effective.indexOf(Modules.timetable) + 1,
);
expect(effective.toSet(), Modules.values.toSet());
});
test('keeps a complete persisted order untouched', () {
final order = Modules.values.reversed.toList();
expect(AppModule.effectiveModuleOrder(settingsWith(order)), order);
});
test('drops duplicates while preserving first occurrence', () {
final order = [Modules.talk, Modules.timetable, Modules.talk];
final effective = AppModule.effectiveModuleOrder(settingsWith(order));
expect(effective.where((m) => m == Modules.talk), hasLength(1));
expect(effective.first, Modules.talk);
expect(effective.toSet(), Modules.values.toSet());
});
});
group('reorderModuleOrder', () {
test('moves within the displayed subset', () {
final effective = AppModule.effectiveModuleOrder(settingsWith([]));
final result = AppModule.reorderModuleOrder(
displayed: effective,
effective: effective,
oldIndex: 0,
newIndex: 2,
);
expect(result[2], effective[0]);
expect(result.toSet(), effective.toSet());
});
test('non-displayed modules keep their slots', () {
// Ticker is capability-filtered from the settings list; a reorder of the
// visible modules must not move or drop it (previously the raw persisted
// indices were used, moving the wrong module).
final effective = AppModule.effectiveModuleOrder(settingsWith([]));
final displayed = effective
.where((m) => m != Modules.ticker)
.toList();
final tickerSlot = effective.indexOf(Modules.ticker);
final result = AppModule.reorderModuleOrder(
displayed: displayed,
effective: effective,
oldIndex: 0,
newIndex: displayed.length - 1,
);
expect(result[tickerSlot], Modules.ticker);
expect(result.toSet(), effective.toSet());
expect(
result.where((m) => m != Modules.ticker).toList(),
displayed.sublist(1)..add(displayed.first),
);
});
});
}
+153
View File
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import 'package:marianum_mobile/view/pages/ticker/ticker_view.dart';
List<TickerNavSection> _sections() => [
TickerNavSection(
title: 'Infos',
pages: [
TickerNavPage(title: 'Über uns', slug: 'about', kind: TickerPageKind.content),
TickerNavPage(
title: 'Webseite',
slug: 'web',
kind: TickerPageKind.redirect,
externalUrl: 'https://marianum-fulda.de',
),
],
),
];
Widget _host() => MaterialApp(
home: TickerScaffold(
sections: _sections(),
homeBuilder: (context, onLinkTap) => const Text('HOME'),
pageBuilder: (context, slug, onLinkTap) => Text('PAGE:$slug'),
),
);
Finder _appBarText(String text) =>
find.descendant(of: find.byType(AppBar), matching: find.text(text));
// The home surface stays mounted (IndexedStack) while a page is open, so
// "which content is shown" is the stack index, not widget presence.
int _shownIndex(WidgetTester tester) =>
tester.widget<IndexedStack>(find.byType(IndexedStack)).index!;
const String _menuTooltip = 'Open navigation menu';
void main() {
group('narrow layout (< 900)', () {
testWidgets('uses a drawer reachable via the burger button', (tester) async {
await tester.pumpWidget(_host());
await tester.pumpAndSettle();
// Nav is hidden behind the drawer, not shown as a sidebar.
expect(find.text('Über uns'), findsNothing);
expect(find.byTooltip(_menuTooltip), findsOneWidget);
await tester.tap(find.byTooltip(_menuTooltip));
await tester.pumpAndSettle();
expect(find.byType(Drawer), findsOneWidget);
expect(find.text('Über uns'), findsOneWidget);
expect(find.text('Aktuelles'), findsOneWidget);
});
});
group('wide layout (>= 900)', () {
Future<void> pumpWide(WidgetTester tester) async {
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host());
await tester.pumpAndSettle();
}
testWidgets('shows a permanent sidebar and no drawer', (tester) async {
await pumpWide(tester);
expect(find.byType(Drawer), findsNothing);
expect(find.byTooltip(_menuTooltip), findsNothing);
// Sidebar nav item is visible without any interaction.
expect(find.text('Über uns'), findsOneWidget);
});
testWidgets('selecting a page swaps content, title and home action', (
tester,
) async {
await pumpWide(tester);
expect(_shownIndex(tester), 0);
expect(find.text('PAGE:about'), findsNothing);
expect(_appBarText('Ticker'), findsOneWidget);
expect(find.byIcon(Icons.home_outlined), findsNothing);
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
expect(_shownIndex(tester), 1);
expect(find.text('PAGE:about'), findsOneWidget);
expect(_appBarText('Über uns'), findsOneWidget);
expect(find.byIcon(Icons.home_outlined), findsOneWidget);
await tester.tap(find.byIcon(Icons.home_outlined));
await tester.pumpAndSettle();
expect(_shownIndex(tester), 0);
expect(find.text('PAGE:about'), findsNothing);
expect(_appBarText('Ticker'), findsOneWidget);
expect(find.byIcon(Icons.home_outlined), findsNothing);
});
});
group('back gesture', () {
testWidgets('pops from a sub-page back to home via the tab navigator', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host());
await tester.pumpAndSettle();
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
expect(_shownIndex(tester), 1);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
// The LocalHistoryEntry is what makes the enclosing tab shell delegate
// the system back to this navigator instead of switching tabs.
expect(navigator.canPop(), isTrue);
await navigator.maybePop();
await tester.pumpAndSettle();
expect(_shownIndex(tester), 0);
expect(_appBarText('Ticker'), findsOneWidget);
expect(navigator.canPop(), isFalse);
});
testWidgets('home selection releases the back interception', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 2000);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_host());
await tester.pumpAndSettle();
await tester.tap(find.text('Über uns'));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.home_outlined));
await tester.pumpAndSettle();
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
expect(navigator.canPop(), isFalse);
});
});
}
@@ -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);
});
}