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