diff --git a/lib/api/demo/data/demo_ticker.dart b/lib/api/demo/data/demo_ticker.dart new file mode 100644 index 0000000..7d537b6 --- /dev/null +++ b/lib/api/demo/data/demo_ticker.dart @@ -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 _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 _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', + ); +} diff --git a/lib/api/errors/ticker_content_unavailable_exception.dart b/lib/api/errors/ticker_content_unavailable_exception.dart new file mode 100644 index 0000000..fcd9082 --- /dev/null +++ b/lib/api/errors/ticker_content_unavailable_exception.dart @@ -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, + ); +} diff --git a/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart index f58b5d7..8e36f9e 100644 --- a/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart +++ b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.dart @@ -55,6 +55,8 @@ enum BreakerArea { files, @JsonValue('NEWS') news, + @JsonValue('TICKER') + ticker, @JsonValue('ROOMPLAN') roomPlan, @JsonValue('GRADES') diff --git a/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.g.dart b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.g.dart index d446ada..9dcea44 100644 --- a/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.g.dart +++ b/lib/api/marianumconnect/queries/get_breakers/get_breakers_response.g.dart @@ -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', diff --git a/lib/api/marianumconnect/queries/get_ticker/get_ticker.dart b/lib/api/marianumconnect/queries/get_ticker/get_ticker.dart new file mode 100644 index 0000000..73ecc27 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker/get_ticker.dart @@ -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 run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('ticker'), + ); + return TickerResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/get_ticker/get_ticker_response.dart b/lib/api/marianumconnect/queries/get_ticker/get_ticker_response.dart new file mode 100644 index 0000000..e2e9bc5 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker/get_ticker_response.dart @@ -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? content; + + TickerResponse({ + required this.schemaVersion, + required this.available, + this.hash, + this.publishedAt, + required this.webUrl, + this.content, + }); + + factory TickerResponse.fromJson(Map json) => + _$TickerResponseFromJson(json); + Map toJson() => _$TickerResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/get_ticker/get_ticker_response.g.dart b/lib/api/marianumconnect/queries/get_ticker/get_ticker_response.g.dart new file mode 100644 index 0000000..57c74ae --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker/get_ticker_response.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_ticker_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TickerResponse _$TickerResponseFromJson(Map 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?, + ); + +Map _$TickerResponseToJson(TickerResponse instance) => + { + 'schemaVersion': instance.schemaVersion, + 'available': instance.available, + 'hash': instance.hash, + 'publishedAt': instance.publishedAt, + 'webUrl': instance.webUrl, + 'content': instance.content, + }; diff --git a/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart new file mode 100644 index 0000000..e6d6cf5 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart @@ -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 run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('ticker/pages'), + ); + return TickerNavResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart new file mode 100644 index 0000000..c771f13 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart @@ -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 sections; + + TickerNavResponse({ + required this.schemaVersion, + required this.navHash, + required this.sections, + }); + + factory TickerNavResponse.fromJson(Map json) => + _$TickerNavResponseFromJson(json); + Map toJson() => _$TickerNavResponseToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class TickerNavSection { + @JsonKey(defaultValue: '') + final String title; + + @JsonKey(defaultValue: []) + final List pages; + + TickerNavSection({required this.title, required this.pages}); + + factory TickerNavSection.fromJson(Map json) => + _$TickerNavSectionFromJson(json); + Map 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 json) => + _$TickerNavPageFromJson(json); + Map toJson() => _$TickerNavPageToJson(this); +} diff --git a/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.g.dart b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.g.dart new file mode 100644 index 0000000..59247c2 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_ticker_nav_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TickerNavResponse _$TickerNavResponseFromJson(Map json) => + TickerNavResponse( + schemaVersion: (json['schemaVersion'] as num?)?.toInt() ?? 1, + navHash: json['navHash'] as String? ?? '', + sections: + (json['sections'] as List?) + ?.map((e) => TickerNavSection.fromJson(e as Map)) + .toList() ?? + [], + ); + +Map _$TickerNavResponseToJson(TickerNavResponse instance) => + { + 'schemaVersion': instance.schemaVersion, + 'navHash': instance.navHash, + 'sections': instance.sections.map((e) => e.toJson()).toList(), + }; + +TickerNavSection _$TickerNavSectionFromJson(Map json) => + TickerNavSection( + title: json['title'] as String? ?? '', + pages: + (json['pages'] as List?) + ?.map((e) => TickerNavPage.fromJson(e as Map)) + .toList() ?? + [], + ); + +Map _$TickerNavSectionToJson(TickerNavSection instance) => + { + 'title': instance.title, + 'pages': instance.pages.map((e) => e.toJson()).toList(), + }; + +TickerNavPage _$TickerNavPageFromJson(Map 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 _$TickerNavPageToJson(TickerNavPage instance) => + { + 'title': instance.title, + 'slug': instance.slug, + 'kind': instance.kind, + 'hash': instance.hash, + 'externalUrl': instance.externalUrl, + 'externalUrlNewTab': instance.externalUrlNewTab, + 'fileUrl': instance.fileUrl, + }; diff --git a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart new file mode 100644 index 0000000..260cd98 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page.dart @@ -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 run() async { + try { + final response = await _dio.get>( + 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? _asMap(dynamic data) { + if (data is Map) return data; + if (data is String && data.isNotEmpty) { + try { + final decoded = jsonDecode(data); + if (decoded is Map) return decoded; + } catch (_) { + return null; + } + } + return null; + } +} diff --git a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart new file mode 100644 index 0000000..4ada7b9 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart @@ -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? 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 json) => + _$TickerPageResponseFromJson(json); + Map toJson() => _$TickerPageResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.g.dart b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.g.dart new file mode 100644 index 0000000..acdeccc --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_ticker_page_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TickerPageResponse _$TickerPageResponseFromJson(Map 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?, + 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 _$TickerPageResponseToJson(TickerPageResponse instance) => + { + '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, + }; diff --git a/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart b/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart new file mode 100644 index 0000000..1dec832 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_page_file/get_ticker_page_file.dart @@ -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 run() async { + try { + final response = await _dio.get>( + 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); + } + } +} diff --git a/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync.dart b/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync.dart new file mode 100644 index 0000000..17906b5 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync.dart @@ -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 run() async { + try { + final response = await _dio.get>( + MarianumConnectEndpoint.resolve('ticker/sync'), + ); + return TickerSyncResponse.fromJson(response.data!); + } on DioException catch (e) { + throw mapMarianumConnectError(e); + } + } +} diff --git a/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync_response.dart b/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync_response.dart new file mode 100644 index 0000000..f85e99a --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync_response.dart @@ -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 json) => + _$TickerSyncResponseFromJson(json); + Map toJson() => _$TickerSyncResponseToJson(this); +} diff --git a/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync_response.g.dart b/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync_response.g.dart new file mode 100644 index 0000000..974eb53 --- /dev/null +++ b/lib/api/marianumconnect/queries/get_ticker_sync/get_ticker_sync_response.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_ticker_sync_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TickerSyncResponse _$TickerSyncResponseFromJson(Map json) => + TickerSyncResponse( + tickerHash: json['tickerHash'] as String?, + navHash: json['navHash'] as String?, + ); + +Map _$TickerSyncResponseToJson(TickerSyncResponse instance) => + { + 'tickerHash': instance.tickerHash, + 'navHash': instance.navHash, + }; diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 32680b8..a55314a 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -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 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 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()); } diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index e375665..356cb1e 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -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 effectiveModuleOrder(ModulesSettings settings) { + final seen = {}; + 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 reorderModuleOrder({ + required List displayed, + required List effective, + required int oldIndex, + required int newIndex, + }) { + final moved = List.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) { - desired = 4; - } else { - desired = 3; - } + // 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 = settings.fixedBottomBarSlots; } @@ -225,6 +277,7 @@ class AppModule { enum Modules { timetable, + ticker, talk, files, marianumMessage, diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart index 35c10dc..7ba12ed 100644 --- a/lib/state/app/modules/settings/bloc/settings_cubit.dart +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -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 { static const _debounceTag = 'settings_persist'; @@ -47,16 +46,17 @@ class SettingsCubit extends HydratedCubit { 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 json) { try { - return _appendNewModules(Settings.fromJson(json)); + return Settings.fromJson(json); } catch (_) { try { - return _appendNewModules( - Settings.fromJson( - _mergeSettings(json, DefaultSettings.get().toJson()), - ), + return Settings.fromJson( + _mergeSettings(json, DefaultSettings.get().toJson()), ); } catch (_) { return DefaultSettings.get(); @@ -64,20 +64,6 @@ class SettingsCubit extends HydratedCubit { } } - // 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? toJson(Settings state) => state.toJson(); diff --git a/lib/state/app/modules/ticker/bloc/ticker_bloc.dart b/lib/state/app/modules/ticker/bloc/ticker_bloc.dart new file mode 100644 index 0000000..350b509 --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_bloc.dart @@ -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 { + @override + Future 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 json) => + TickerState.fromJson(json); + + @override + Map? toStorage(TickerState state) => state.toJson(); +} diff --git a/lib/state/app/modules/ticker/bloc/ticker_event.dart b/lib/state/app/modules/ticker/bloc/ticker_event.dart new file mode 100644 index 0000000..8a0e5bb --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_event.dart @@ -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 {} + +class TickerLoadEvent extends TickerEvent {} diff --git a/lib/state/app/modules/ticker/bloc/ticker_state.dart b/lib/state/app/modules/ticker/bloc/ticker_state.dart new file mode 100644 index 0000000..d00ab2b --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_state.dart @@ -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 json) => + _$TickerStateFromJson(json); +} diff --git a/lib/state/app/modules/ticker/bloc/ticker_state.freezed.dart b/lib/state/app/modules/ticker/bloc/ticker_state.freezed.dart new file mode 100644 index 0000000..fecb91a --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_state.freezed.dart @@ -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 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 get copyWith => _$TickerStateCopyWithImpl(this as TickerState, _$identity); + + /// Serializes this TickerState to a JSON map. + Map 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 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 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? 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 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 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? 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 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 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 diff --git a/lib/state/app/modules/ticker/bloc/ticker_state.g.dart b/lib/state/app/modules/ticker/bloc/ticker_state.g.dart new file mode 100644 index 0000000..4726e1c --- /dev/null +++ b/lib/state/app/modules/ticker/bloc/ticker_state.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ticker_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TickerState _$TickerStateFromJson(Map json) => _TickerState( + ticker: json['ticker'] == null + ? null + : TickerResponse.fromJson(json['ticker'] as Map), + nav: json['nav'] == null + ? null + : TickerNavResponse.fromJson(json['nav'] as Map), +); + +Map _$TickerStateToJson(_TickerState instance) => + {'ticker': instance.ticker, 'nav': instance.nav}; diff --git a/lib/state/app/modules/ticker/repository/ticker_repository.dart b/lib/state/app/modules/ticker/repository/ticker_repository.dart new file mode 100644 index 0000000..40d09d3 --- /dev/null +++ b/lib/state/app/modules/ticker/repository/ticker_repository.dart @@ -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 { + Future getTicker() { + if (DemoMode.active) return Future.value(DemoTicker.ticker()); + return GetTicker().run(); + } + + Future getNav() { + if (DemoMode.active) return Future.value(DemoTicker.nav()); + return GetTickerNav().run(); + } + + Future getPage(String slug) { + if (DemoMode.active) return Future.value(DemoTicker.page(slug)); + return GetTickerPage(slug).run(); + } + + Future getPageFile(String slug) { + if (DemoMode.active) return Future.value(Uint8List(0)); + return GetTickerPageFile(slug).run(); + } +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart index 71005ed..0110b49 100644 --- a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -15,10 +15,9 @@ T _$identity(T value) => value; /// @nodoc mixin _$TimetableState { - Map 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 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; diff --git a/lib/storage/modules_settings.g.dart b/lib/storage/modules_settings.g.dart index e97774b..9267e99 100644 --- a/lib/storage/modules_settings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -31,6 +31,7 @@ Map _$ModulesSettingsToJson( const _$ModulesEnumMap = { Modules.timetable: 'timetable', + Modules.ticker: 'ticker', Modules.talk: 'talk', Modules.files: 'files', Modules.marianumMessage: 'marianumMessage', diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index a261077..82aa4e2 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -25,6 +25,7 @@ class DefaultSettings { modulesSettings: ModulesSettings( moduleOrder: [ Modules.timetable, + Modules.ticker, Modules.talk, Modules.files, Modules.marianumMessage, diff --git a/lib/view/pages/settings/modules_settings_page.dart b/lib/view/pages/settings/modules_settings_page.dart index a652b60..17e2f1f 100644 --- a/lib/view/pages/settings/modules_settings_page.dart +++ b/lib/view/pages/settings/modules_settings_page.dart @@ -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, + ); }, ); }, diff --git a/lib/view/pages/ticker/ticker_page_view.dart b/lib/view/pages/ticker/ticker_page_view.dart new file mode 100644 index 0000000..684bc99 --- /dev/null +++ b/lib/view/pages/ticker/ticker_page_view.dart @@ -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(), + ), + ); +} diff --git a/lib/view/pages/ticker/ticker_view.dart b/lib/view/pages/ticker/ticker_view.dart new file mode 100644 index 0000000..b2200bf --- /dev/null +++ b/lib/view/pages/ticker/ticker_view.dart @@ -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>( + create: (context) => TickerBloc(), + child: (context, bloc, _) { + final sections = + context + .watch() + .state + .data + ?.nav + ?.sections + .where((section) => section.pages.isNotEmpty) + .toList() ?? + const []; + + return TickerScaffold( + sections: sections, + homeBuilder: (context, onLinkTap) => + LoadableStateConsumer( + 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 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 createState() => _TickerScaffoldState(); +} + +class _TickerScaffoldState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + 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'), + ), + ), + ], + ); +} diff --git a/lib/view/pages/ticker/widgets/ticker_content_card.dart b/lib/view/pages/ticker/widgets/ticker_content_card.dart new file mode 100644 index 0000000..1ea07b7 --- /dev/null +++ b/lib/view/pages/ticker/widgets/ticker_content_card.dart @@ -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), + ); +} diff --git a/lib/view/pages/ticker/widgets/ticker_nav_list.dart b/lib/view/pages/ticker/widgets/ticker_nav_list.dart new file mode 100644 index 0000000..a9f38a2 --- /dev/null +++ b/lib/view/pages/ticker/widgets/ticker_nav_list.dart @@ -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 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 = [ + 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 _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; + } + } +} diff --git a/lib/view/pages/ticker/widgets/ticker_page_body.dart b/lib/view/pages/ticker/widgets/ticker_page_body.dart new file mode 100644 index 0000000..c74c5ed --- /dev/null +++ b/lib/view/pages/ticker/widgets/ticker_page_body.dart @@ -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 createState() => _TickerPageBodyState(); +} + +class _TickerPageBodyState extends State { + final TickerRepository _repo = TickerRepository(); + late Future _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( + 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 _bytes; + + @override + void initState() { + super.initState(); + _bytes = widget.repo.getPageFile(widget.slug); + } + + @override + Widget build(BuildContext context) => FutureBuilder( + 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); + }, + ); +} diff --git a/lib/widget/prosemirror/pm_callout_view.dart b/lib/widget/prosemirror/pm_callout_view.dart new file mode 100644 index 0000000..28b2cc2 --- /dev/null +++ b/lib/widget/prosemirror/pm_callout_view.dart @@ -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.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)), + ], + ), + ), + ); + } +} diff --git a/lib/widget/prosemirror/pm_document_view.dart b/lib/widget/prosemirror/pm_document_view.dart new file mode 100644 index 0000000..19547a1 --- /dev/null +++ b/lib/widget/prosemirror/pm_document_view.dart @@ -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 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 nodes, {double gap = AppSpacing.sm}) { + final children = []; + 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 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 = []; + 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().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(); +} diff --git a/lib/widget/prosemirror/pm_image_view.dart b/lib/widget/prosemirror/pm_image_view.dart new file mode 100644 index 0000000..950d54d --- /dev/null +++ b/lib/widget/prosemirror/pm_image_view.dart @@ -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( + 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); + } +} diff --git a/lib/widget/prosemirror/pm_json_view.dart b/lib/widget/prosemirror/pm_json_view.dart new file mode 100644 index 0000000..c2b2575 --- /dev/null +++ b/lib/widget/prosemirror/pm_json_view.dart @@ -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 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 createState() => _PmJsonViewState(); +} + +class _PmJsonViewState extends State { + PmNode? _shown; + PmNode? _pending; + List _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 = []; + _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 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); + } +} diff --git a/lib/widget/prosemirror/pm_node.dart b/lib/widget/prosemirror/pm_node.dart new file mode 100644 index 0000000..a2fcfbb --- /dev/null +++ b/lib/widget/prosemirror/pm_node.dart @@ -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 get children => const []; + + factory PmNode.fromJson(Map 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 _parseChildren(dynamic content) { + if (content is! List) return const []; + return content + .whereType>() + .map((e) => PmNode.fromJson(e.cast())) + .toList(growable: false); + } + + static List _parseMarks(dynamic marks) { + if (marks is! List) return const []; + return marks + .whereType>() + .map((m) { + final map = m.cast(); + 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 _attrs(dynamic attrs) => + attrs is Map ? attrs.cast() : 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 attrs; + + const PmMark({required this.type, this.attrs = const {}}); +} + +class PmParagraph extends PmNode { + final TextAlign? align; + @override + final List children; + + const PmParagraph({this.align, this.children = const []}); +} + +class PmHeading extends PmNode { + final int level; + final TextAlign? align; + @override + final List children; + + const PmHeading({required this.level, this.align, this.children = const []}); +} + +class PmText extends PmNode { + final String text; + final List marks; + + const PmText({required this.text, this.marks = const []}); +} + +class PmBulletList extends PmNode { + @override + final List children; + + const PmBulletList({this.children = const []}); +} + +class PmOrderedList extends PmNode { + final int start; + @override + final List children; + + const PmOrderedList({this.start = 1, this.children = const []}); +} + +class PmListItem extends PmNode { + @override + final List children; + + const PmListItem({this.children = const []}); +} + +class PmBlockquote extends PmNode { + @override + final List children; + + const PmBlockquote({this.children = const []}); +} + +class PmCodeBlock extends PmNode { + final String? language; + @override + final List 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 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 children; + + const PmCallout({required this.variant, this.children = const []}); +} + +class PmTable extends PmNode { + @override + final List children; + + const PmTable({this.children = const []}); +} + +class PmTableRow extends PmNode { + @override + final List children; + + const PmTableRow({this.children = const []}); +} + +class PmTableCell extends PmNode { + final bool header; + final int colspan; + final int rowspan; + @override + final List 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 children; + + const PmUnknown({required this.rawType, this.children = const []}); +} diff --git a/lib/widget/prosemirror/pm_render_scope.dart b/lib/widget/prosemirror/pm_render_scope.dart new file mode 100644 index 0000000..7d360be --- /dev/null +++ b/lib/widget/prosemirror/pm_render_scope.dart @@ -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(); + + @override + bool updateShouldNotify(PmRenderScope oldWidget) => + oldWidget.onLinkTap != onLinkTap; +} diff --git a/lib/widget/prosemirror/pm_rich_text.dart b/lib/widget/prosemirror/pm_rich_text.dart new file mode 100644 index 0000000..bb8d9d0 --- /dev/null +++ b/lib/widget/prosemirror/pm_rich_text.dart @@ -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 inlines; + final TextStyle baseStyle; + final TextAlign textAlign; + + const PmRichText({ + required this.inlines, + required this.baseStyle, + this.textAlign = TextAlign.start, + super.key, + }); + + @override + State createState() => _PmRichTextState(); +} + +class _PmRichTextState extends State { + final List _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 = []; + 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 marks, + TextStyle base, + ThemeData theme, { + required void Function(String href) onLink, + }) { + var style = base; + final decorations = []; + 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 = { + '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), +}; diff --git a/lib/widget/prosemirror/pm_table_view.dart b/lib/widget/prosemirror/pm_table_view.dart new file mode 100644 index 0000000..dcdc6cb --- /dev/null +++ b/lib/widget/prosemirror/pm_table_view.dart @@ -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().toList(); + if (rows.isEmpty) return const SizedBox.shrink(); + + final occupied = >{}; + final placements = []; + var columnCount = 0; + + for (var r = 0; r < rows.length; r++) { + var col = 0; + for (final cell in rows[r].children.whereType()) { + 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] ??= {}; + 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), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 96e2543..b523775 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/api/marianumconnect/ticker_models_test.dart b/test/api/marianumconnect/ticker_models_test.dart new file mode 100644 index 0000000..9ae99de --- /dev/null +++ b/test/api/marianumconnect/ticker_models_test.dart @@ -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>()); + 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( + requestOptions: requestOptions, + statusCode: 404, + data: {'error': 'CONTENT_UNAVAILABLE', 'webUrl': '/ticker/p/foo'}, + ), + ), + ); + + expect( + () => GetTickerPage('foo', dio: dio).run(), + throwsA( + isA() + .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( + requestOptions: requestOptions, + statusCode: 404, + data: {'error': 'NOT_FOUND', 'webUrl': '/ticker/p/foo'}, + ), + ), + ); + + expect( + () => GetTickerPage('foo', dio: dio).run(), + throwsA(isNot(isA())), + ); + }); + }); +} + +/// 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> get( + String path, { + Object? data, + Map? queryParameters, + Options? options, + CancelToken? cancelToken, + ProgressCallback? onReceiveProgress, + }) => Future>.error(error); + + @override + dynamic noSuchMethod(Invocation invocation) => + super.noSuchMethod(invocation); +} diff --git a/test/state/app_modules_order_test.dart b/test/state/app_modules_order_test.dart new file mode 100644 index 0000000..7732c79 --- /dev/null +++ b/test/state/app_modules_order_test.dart @@ -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 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), + ); + }); + }); +} diff --git a/test/view/ticker/ticker_scaffold_test.dart b/test/view/ticker/ticker_scaffold_test.dart new file mode 100644 index 0000000..67f03da --- /dev/null +++ b/test/view/ticker/ticker_scaffold_test.dart @@ -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 _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(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 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(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(find.byType(Navigator)); + expect(navigator.canPop(), isFalse); + }); + }); +} diff --git a/test/widget/prosemirror/fixtures/README.md b/test/widget/prosemirror/fixtures/README.md new file mode 100644 index 0000000..9aaaa31 --- /dev/null +++ b/test/widget/prosemirror/fixtures/README.md @@ -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. diff --git a/test/widget/prosemirror/fixtures/callout.json b/test/widget/prosemirror/fixtures/callout.json new file mode 100644 index 0000000..5809704 --- /dev/null +++ b/test/widget/prosemirror/fixtures/callout.json @@ -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 + } + } + ] +} diff --git a/test/widget/prosemirror/fixtures/image.json b/test/widget/prosemirror/fixtures/image.json new file mode 100644 index 0000000..391bcb2 --- /dev/null +++ b/test/widget/prosemirror/fixtures/image.json @@ -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 + } + } + ] +} diff --git a/test/widget/prosemirror/fixtures/kitchen-sink.json b/test/widget/prosemirror/fixtures/kitchen-sink.json new file mode 100644 index 0000000..78298a6 --- /dev/null +++ b/test/widget/prosemirror/fixtures/kitchen-sink.json @@ -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 + } + } + ] +} diff --git a/test/widget/prosemirror/fixtures/lists.json b/test/widget/prosemirror/fixtures/lists.json new file mode 100644 index 0000000..94ef7d9 --- /dev/null +++ b/test/widget/prosemirror/fixtures/lists.json @@ -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 + } + } + ] +} diff --git a/test/widget/prosemirror/fixtures/table.json b/test/widget/prosemirror/fixtures/table.json new file mode 100644 index 0000000..9371f6f --- /dev/null +++ b/test/widget/prosemirror/fixtures/table.json @@ -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 + } + } + ] +} diff --git a/test/widget/prosemirror/fixtures/text-marks.json b/test/widget/prosemirror/fixtures/text-marks.json new file mode 100644 index 0000000..b8f0035 --- /dev/null +++ b/test/widget/prosemirror/fixtures/text-marks.json @@ -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" + } + ] + } + ] +} diff --git a/test/widget/prosemirror/pm_document_view_test.dart b/test/widget/prosemirror/pm_document_view_test.dart new file mode 100644 index 0000000..b5d0846 --- /dev/null +++ b/test/widget/prosemirror/pm_document_view_test.dart @@ -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 _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 = []; + await tester.pumpWidget(_host(Brightness.light, onLinkTap: tapped.add)); + await tester.pump(); + + final richTexts = tester.widgetList(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; + } +} diff --git a/test/widget/prosemirror/pm_fixtures_render_test.dart b/test/widget/prosemirror/pm_fixtures_render_test.dart new file mode 100644 index 0000000..d9ee0bc --- /dev/null +++ b/test/widget/prosemirror/pm_fixtures_render_test.dart @@ -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 _loadFixture(String name) { + final raw = File( + 'test/widget/prosemirror/fixtures/$name', + ).readAsStringSync(); + return jsonDecode(raw) as Map; +} + +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()); + 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); + }); + } +} diff --git a/test/widget/prosemirror/pm_node_parse_test.dart b/test/widget/prosemirror/pm_node_parse_test.dart new file mode 100644 index 0000000..88a038f --- /dev/null +++ b/test/widget/prosemirror/pm_node_parse_test.dart @@ -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 _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()); + 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()); + expect((node as PmUnknown).rawType, 'youTubeEmbed'); + expect(node.children.single, isA()); + }); + + 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, + ) + 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; + final doc = PmNode.fromJson(decoded); + expect(doc.children.any(_hasUnknown), isFalse); + }); +}