Compare commits
2 Commits
9b74c9fd81
...
cedeb06569
| Author | SHA1 | Date | |
|---|---|---|---|
| cedeb06569 | |||
| 1114291313 |
@@ -350,3 +350,15 @@ hs_err_pid*
|
|||||||
|
|
||||||
*.idea*
|
*.idea*
|
||||||
**/.DS_store
|
**/.DS_store
|
||||||
|
|
||||||
|
# Fastlane (Play-Store-Upload)
|
||||||
|
android/fastlane/report.xml
|
||||||
|
android/fastlane/Preview.html
|
||||||
|
android/fastlane/play-service-account.json
|
||||||
|
android/fastlane/README.md.bak
|
||||||
|
# lokale Screenshots außerhalb der Fastlane-Metadaten
|
||||||
|
/screenshots/
|
||||||
|
# Fastlane-Bild-PNGs sind generierte Kopien; kanonisch/versioniert liegen die
|
||||||
|
# Screenshots unter materials/screenshots/. Nur die leeren Ordner (.gitkeep)
|
||||||
|
# bleiben eingecheckt, damit supply die Struktur vorfindet.
|
||||||
|
android/fastlane/metadata/android/**/images/**/*.png
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package_name("eu.mhsl.marianum.mobile.client")
|
||||||
|
|
||||||
|
# Service-Account-JSON für die Google Play Developer API. Pfad über die
|
||||||
|
# Umgebungsvariable SUPPLY_JSON_KEY setzen (die Datei selbst wird per
|
||||||
|
# .gitignore nicht eingecheckt).
|
||||||
|
json_key_file(ENV["SUPPLY_JSON_KEY"] || "fastlane/play-service-account.json")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
default_platform(:android)
|
||||||
|
|
||||||
|
# Play-Store-Auslieferung über fastlane supply. Die Screenshots erzeugt
|
||||||
|
# ../tool/screenshots.sh (Demo-Login + integration_test) direkt in
|
||||||
|
# metadata/android/<locale>/images/. Texte/Changelogs liegen daneben in
|
||||||
|
# metadata/android/<locale>/.
|
||||||
|
platform :android do
|
||||||
|
desc "Nur Screenshots hochladen (verändert keine Texte/Binaries)"
|
||||||
|
lane :upload_screenshots do
|
||||||
|
upload_to_play_store(
|
||||||
|
skip_upload_apk: true,
|
||||||
|
skip_upload_aab: true,
|
||||||
|
skip_upload_metadata: true,
|
||||||
|
skip_upload_changelogs: true,
|
||||||
|
skip_upload_images: false,
|
||||||
|
skip_upload_screenshots: false,
|
||||||
|
metadata_path: "./fastlane/metadata/android",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Store-Texte, Changelogs und Screenshots hochladen (keine Binaries)"
|
||||||
|
lane :upload_metadata do
|
||||||
|
upload_to_play_store(
|
||||||
|
skip_upload_apk: true,
|
||||||
|
skip_upload_aab: true,
|
||||||
|
metadata_path: "./fastlane/metadata/android",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# fastlane-Plugins (aktuell keine). Datei vorhanden, damit `fastlane` das
|
||||||
|
# Plugin-Handling initialisiert.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Fastlane – Play-Store-Screenshots & Upload
|
||||||
|
|
||||||
|
Schritt 2 der Play-Store-Automatisierung. Die App muss dafür **nicht** neu
|
||||||
|
gebaut werden – der Demo-Modus (`demo@`-Login) liefert die kompletten Inhalte
|
||||||
|
aus Fixtures.
|
||||||
|
|
||||||
|
## 1. Screenshots aufnehmen
|
||||||
|
|
||||||
|
Ein Gerät/Emulator (≥ Android 8) per `adb devices` sichtbar, dann aus dem
|
||||||
|
**Client**-Wurzelverzeichnis:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tool/screenshots.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Fährt Phone (1080×1920), 7″ (1200×1920) und 10″ (1600×2560) nacheinander durch
|
||||||
|
(setzt die Displaygröße per `adb shell wm size/density`) und legt die PNGs in
|
||||||
|
`metadata/android/de-DE/images/{phone,sevenInch,tenInch}Screenshots/` ab.
|
||||||
|
|
||||||
|
- Nur eine Größe: `PROFILES="phone" tool/screenshots.sh`
|
||||||
|
- Profile-Build (flüssigere Frames): `BUILD_MODE=profile tool/screenshots.sh`
|
||||||
|
|
||||||
|
## 2. Hochladen
|
||||||
|
|
||||||
|
Service-Account-JSON der Google Play Developer API besorgen und referenzieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export SUPPLY_JSON_KEY=/pfad/zu/play-service-account.json
|
||||||
|
cd android
|
||||||
|
fastlane upload_screenshots # nur Bilder
|
||||||
|
# oder
|
||||||
|
fastlane upload_metadata # Bilder + Texte/Changelogs, keine Binaries
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Service-Account-Key wird **nicht** eingecheckt (siehe `.gitignore`).
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:marianum_mobile/app.dart';
|
||||||
|
import 'package:marianum_mobile/main.dart' as app;
|
||||||
|
import 'package:marianum_mobile/routing/app_routes.dart';
|
||||||
|
import 'package:marianum_mobile/state/app/modules/app_modules.dart';
|
||||||
|
import 'package:marianum_mobile/state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import 'package:marianum_mobile/view/pages/talk/widgets/chat_tile.dart';
|
||||||
|
|
||||||
|
/// Marketing-Screenshot-Lauf für die Stores. Loggt sich über den Demo-Login
|
||||||
|
/// (`demo@…`, siehe DemoMode) ein und nimmt einen kuratierten Satz Screens auf:
|
||||||
|
/// Stundenplan (hell + dunkel), Talk-Liste, Talk-Chat, Dateien, „Mehr"-Bereich
|
||||||
|
/// und Einstellungen. Ausgeführt über `flutter drive` mit
|
||||||
|
/// test_driver/integration_test.dart, orchestriert von tool/screenshots.sh
|
||||||
|
/// (Phone / 7″ / 10″).
|
||||||
|
///
|
||||||
|
/// Kein Netzwerk: der Demo-Modus beantwortet allen Backend-Verkehr aus
|
||||||
|
/// Fixtures, daher ist der Lauf deterministisch.
|
||||||
|
Future<void> main() async {
|
||||||
|
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
testWidgets('Store-Screenshots über den Demo-Login', (tester) async {
|
||||||
|
// Der integration_test-Harness installiert eigene Error-Handler. app()
|
||||||
|
// überschreibt sie in main() durch die produktiven (ClientErrorReporter),
|
||||||
|
// was die End-of-Test-Buchführung des Harness sprengt (_pendingExceptionDetails).
|
||||||
|
// Daher vorher sichern und nach main() zurücksetzen.
|
||||||
|
final harnessFlutterOnError = FlutterError.onError;
|
||||||
|
final harnessDispatcherOnError = PlatformDispatcher.instance.onError;
|
||||||
|
|
||||||
|
_log('main() starten');
|
||||||
|
await app.main();
|
||||||
|
|
||||||
|
FlutterError.onError = harnessFlutterOnError;
|
||||||
|
PlatformDispatcher.instance.onError = harnessDispatcherOnError;
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
_log('Login');
|
||||||
|
await _login(tester);
|
||||||
|
|
||||||
|
_log('warte auf App-Shell');
|
||||||
|
await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.byType(App),
|
||||||
|
timeout: const Duration(seconds: 20),
|
||||||
|
);
|
||||||
|
_log('warte auf Post-Login-Splash-Ende');
|
||||||
|
await _pumpUntilGone(
|
||||||
|
tester,
|
||||||
|
find.byKey(const ValueKey('post-login-splash')),
|
||||||
|
timeout: const Duration(seconds: 12),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
// Screenshots auf Android brauchen eine einmalige Umwandlung der
|
||||||
|
// Flutter-Surface in eine lesbare Image-Texture. Erst hier – nach Login und
|
||||||
|
// Splash –, damit der sichtbare Screen erst kurz vor der Aufnahme „einfriert".
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
_log('convertFlutterSurfaceToImage');
|
||||||
|
await binding.convertFlutterSurfaceToImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 + 2: Stundenplan in beiden Themes.
|
||||||
|
await _goTo(tester, Modules.timetable);
|
||||||
|
_setTheme(tester, ThemeMode.light);
|
||||||
|
await _capture(tester, binding, '1_stundenplan_hell');
|
||||||
|
_setTheme(tester, ThemeMode.dark);
|
||||||
|
await _capture(tester, binding, '2_stundenplan_dunkel');
|
||||||
|
|
||||||
|
// Restlicher Satz einheitlich im hellen Theme.
|
||||||
|
_setTheme(tester, ThemeMode.light);
|
||||||
|
|
||||||
|
// 3: Talk-Liste.
|
||||||
|
await _goTo(tester, Modules.talk);
|
||||||
|
await _capture(tester, binding, '3_talk_liste');
|
||||||
|
|
||||||
|
// 4: Talk-Chat (ersten Chat der Liste öffnen).
|
||||||
|
await tester.tap(find.byType(ChatTile).first);
|
||||||
|
await _capture(tester, binding, '4_talk_chat');
|
||||||
|
_popPushedPage(tester);
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
// 5: Dateien.
|
||||||
|
await _goTo(tester, Modules.files);
|
||||||
|
await _capture(tester, binding, '5_dateien');
|
||||||
|
|
||||||
|
// 6: „Mehr"-Bereich (letzter Tab, hinter den Modul-Tabs).
|
||||||
|
_goToMore(tester);
|
||||||
|
await _capture(tester, binding, '6_mehr');
|
||||||
|
|
||||||
|
// 7: Einstellungen (Vollbild-Push).
|
||||||
|
AppRoutes.openSettings(tester.element(find.byType(App)));
|
||||||
|
await _capture(tester, binding, '7_einstellungen');
|
||||||
|
_popPushedPage(tester);
|
||||||
|
|
||||||
|
_log('fertig');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String message) => debugPrint('SHOTS: $message');
|
||||||
|
|
||||||
|
Future<void> _login(WidgetTester tester) async {
|
||||||
|
final loginVisible = await _pumpUntil(
|
||||||
|
tester,
|
||||||
|
find.byKey(const Key('login-username-field')),
|
||||||
|
);
|
||||||
|
if (!loginVisible) {
|
||||||
|
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const Key('login-username-field')),
|
||||||
|
'demo@screenshots',
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byKey(const Key('login-password-field')), 'demo');
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.byKey(const Key('login-submit-button')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt das App-Theme über den [SettingsCubit] (MaterialApp folgt
|
||||||
|
/// `settings.appTheme`). `val(write: true)` plant den Emit als Microtask.
|
||||||
|
void _setTheme(WidgetTester tester, ThemeMode mode) {
|
||||||
|
final context = tester.element(find.byType(App));
|
||||||
|
context.read<SettingsCubit>().val(write: true).appTheme = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigiert zu [module] – über den Bottom-Tab, wenn er in der Leiste liegt,
|
||||||
|
/// sonst als Vollbild-Push. Größenunabhängig, da die Tab-Anzahl je Gerät variiert.
|
||||||
|
Future<void> _goTo(WidgetTester tester, Modules module) async {
|
||||||
|
final appFinder = find.byType(App);
|
||||||
|
if (appFinder.evaluate().isEmpty) {
|
||||||
|
_log('WARN: App-Shell fehlt, kann $module nicht ansteuern');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final context = tester.element(appFinder);
|
||||||
|
if (!AppRoutes.goToTab(context, module)) {
|
||||||
|
final resolved = AppModule.modules(context)[module];
|
||||||
|
if (resolved != null) AppRoutes.openModule(context, resolved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Springt auf den „Mehr"-Tab. Der liegt hinter den Modul-Tabs, sein Index ist
|
||||||
|
/// also die Anzahl der Bottom-Bar-Module.
|
||||||
|
void _goToMore(WidgetTester tester) {
|
||||||
|
final context = tester.element(find.byType(App));
|
||||||
|
final moreIndex = AppModule.getBottomBarModules(context).length;
|
||||||
|
app.Main.bottomNavigator.jumpToTab(moreIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schließt einen per [AppRoutes] gepushten Vollbild-Screen wieder.
|
||||||
|
void _popPushedPage(WidgetTester tester) {
|
||||||
|
final navigator = AppRoutes.rootNavigatorKey.currentState;
|
||||||
|
if (navigator != null && navigator.canPop()) navigator.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lässt die Oberfläche zur Ruhe kommen und legt den Screenshot ab.
|
||||||
|
Future<void> _capture(
|
||||||
|
WidgetTester tester,
|
||||||
|
IntegrationTestWidgetsFlutterBinding binding,
|
||||||
|
String name,
|
||||||
|
) async {
|
||||||
|
// Feste Setzzeit statt „warte auf Spinner-Ende": die Demo-Fixtures kommen
|
||||||
|
// synchron (keine künstlichen Delays), aber Module wie Dateien/Chat laden erst
|
||||||
|
// beim Navigieren nach. Eine globale CircularProgressIndicator-Prüfung taugt
|
||||||
|
// nicht, weil die PersistentTabView alle Tabs am Leben hält und
|
||||||
|
// Avatar-Platzhalter dauerhaft einen Spinner zeigen. In 500ms-Schritten
|
||||||
|
// pumpen, damit Timer/Microtasks (u.a. der Theme-Emit) durchlaufen.
|
||||||
|
await _settle(tester, const Duration(seconds: 6));
|
||||||
|
_log('-> $name: takeScreenshot');
|
||||||
|
await binding.takeScreenshot(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pumpt [duration] in 500ms-Schritten ab, damit die App async Fixtures laden
|
||||||
|
/// und ihre Frames rendern kann (Ersatz für pumpAndSettle, das an den
|
||||||
|
/// Dauer-Animationen hängen bleibt).
|
||||||
|
Future<void> _settle(WidgetTester tester, Duration duration) async {
|
||||||
|
final steps = duration.inMilliseconds ~/ 500;
|
||||||
|
for (var i = 0; i < steps; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pumpt in kurzen Schritten, bis [finder] erscheint oder [timeout] abläuft.
|
||||||
|
/// Gibt zurück, ob [finder] gefunden wurde.
|
||||||
|
Future<bool> _pumpUntil(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (finder.evaluate().isNotEmpty) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gegenstück zu [_pumpUntil]: pumpt, bis [finder] nichts mehr trifft.
|
||||||
|
Future<bool> _pumpUntilGone(
|
||||||
|
WidgetTester tester,
|
||||||
|
Finder finder, {
|
||||||
|
Duration timeout = const Duration(seconds: 10),
|
||||||
|
}) async {
|
||||||
|
final end = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(end)) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
if (finder.evaluate().isEmpty) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -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,
|
files,
|
||||||
@JsonValue('NEWS')
|
@JsonValue('NEWS')
|
||||||
news,
|
news,
|
||||||
|
@JsonValue('TICKER')
|
||||||
|
ticker,
|
||||||
@JsonValue('ROOMPLAN')
|
@JsonValue('ROOMPLAN')
|
||||||
roomPlan,
|
roomPlan,
|
||||||
@JsonValue('GRADES')
|
@JsonValue('GRADES')
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const _$BreakerAreaEnumMap = {
|
|||||||
BreakerArea.talk: 'TALK',
|
BreakerArea.talk: 'TALK',
|
||||||
BreakerArea.files: 'FILES',
|
BreakerArea.files: 'FILES',
|
||||||
BreakerArea.news: 'NEWS',
|
BreakerArea.news: 'NEWS',
|
||||||
|
BreakerArea.ticker: 'TICKER',
|
||||||
BreakerArea.roomPlan: 'ROOMPLAN',
|
BreakerArea.roomPlan: 'ROOMPLAN',
|
||||||
BreakerArea.grades: 'GRADES',
|
BreakerArea.grades: 'GRADES',
|
||||||
BreakerArea.holidays: 'HOLIDAYS',
|
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/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.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/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 '../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../model/account_data.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/chat_view.dart';
|
||||||
import '../view/pages/talk/details/message_reactions.dart';
|
import '../view/pages/talk/details/message_reactions.dart';
|
||||||
import '../view/pages/talk/talk_navigator.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 '../view/pages/timetable/custom_events/custom_events_view.dart';
|
||||||
import '../widget/debug/cache_view.dart';
|
import '../widget/debug/cache_view.dart';
|
||||||
import '../widget/file_viewer.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) {
|
static void openQrShare(BuildContext context) {
|
||||||
pushScreen(context, withNavBar: false, screen: const QrShareView());
|
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 '../../../api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
|
||||||
import '../../../routing/app_routes.dart';
|
import '../../../routing/app_routes.dart';
|
||||||
|
import '../../../storage/modules_settings.dart';
|
||||||
import '../../../view/pages/files/files.dart';
|
import '../../../view/pages/files/files.dart';
|
||||||
import '../../../view/pages/grade_averages/grade_averages_view.dart';
|
import '../../../view/pages/grade_averages/grade_averages_view.dart';
|
||||||
import '../../../view/pages/holidays/holidays_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/marianum_message/marianum_message_list_view.dart';
|
||||||
import '../../../view/pages/more/roomplan/roomplan.dart';
|
import '../../../view/pages/more/roomplan/roomplan.dart';
|
||||||
import '../../../view/pages/talk/chat_list.dart';
|
import '../../../view/pages/talk/chat_list.dart';
|
||||||
|
import '../../../view/pages/ticker/ticker_view.dart';
|
||||||
import '../../../view/pages/timetable/timetable.dart';
|
import '../../../view/pages/timetable/timetable.dart';
|
||||||
import '../../../widget/breaker/breaker.dart';
|
import '../../../widget/breaker/breaker.dart';
|
||||||
import '../../../widget/centered_leading.dart';
|
import '../../../widget/centered_leading.dart';
|
||||||
@@ -48,6 +50,15 @@ class AppModule {
|
|||||||
breakerArea: BreakerArea.timetable,
|
breakerArea: BreakerArea.timetable,
|
||||||
create: Timetable.new,
|
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: AppModule(
|
||||||
Modules.talk,
|
Modules.talk,
|
||||||
name: 'Talk',
|
name: 'Talk',
|
||||||
@@ -136,13 +147,59 @@ class AppModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
for (var element in settings.val().modulesSettings.moduleOrder.where(
|
for (var element in effectiveModuleOrder(
|
||||||
(element) => available.containsKey(element),
|
settings.val().modulesSettings,
|
||||||
))
|
).where((element) => available.containsKey(element)))
|
||||||
element: available[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 minBottomBarSlots = 3;
|
||||||
static const int maxBottomBarSlots = 5;
|
static const int maxBottomBarSlots = 5;
|
||||||
|
|
||||||
@@ -152,14 +209,9 @@ class AppModule {
|
|||||||
|
|
||||||
int desired;
|
int desired;
|
||||||
if (settings.autoFillBottomBar) {
|
if (settings.autoFillBottomBar) {
|
||||||
final width = MediaQuery.of(context).size.width;
|
// 4 content tabs so the bar shows 5 entries including "Mehr" — enough
|
||||||
if (width >= 840) {
|
// for the Ticker module without pushing "Dateien" into the overflow.
|
||||||
desired = 5;
|
|
||||||
} else if (width >= 600) {
|
|
||||||
desired = 4;
|
desired = 4;
|
||||||
} else {
|
|
||||||
desired = 3;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
desired = settings.fixedBottomBarSlots;
|
desired = settings.fixedBottomBarSlots;
|
||||||
}
|
}
|
||||||
@@ -225,6 +277,7 @@ class AppModule {
|
|||||||
|
|
||||||
enum Modules {
|
enum Modules {
|
||||||
timetable,
|
timetable,
|
||||||
|
ticker,
|
||||||
talk,
|
talk,
|
||||||
files,
|
files,
|
||||||
marianumMessage,
|
marianumMessage,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|||||||
import '../../../../../storage/settings.dart';
|
import '../../../../../storage/settings.dart';
|
||||||
import '../../../../../utils/debouncer.dart';
|
import '../../../../../utils/debouncer.dart';
|
||||||
import '../../../../../view/pages/settings/data/default_settings.dart';
|
import '../../../../../view/pages/settings/data/default_settings.dart';
|
||||||
import '../../app_modules.dart';
|
|
||||||
|
|
||||||
class SettingsCubit extends HydratedCubit<Settings> {
|
class SettingsCubit extends HydratedCubit<Settings> {
|
||||||
static const _debounceTag = 'settings_persist';
|
static const _debounceTag = 'settings_persist';
|
||||||
@@ -47,16 +46,17 @@ class SettingsCubit extends HydratedCubit<Settings> {
|
|||||||
emit(DefaultSettings.get());
|
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
|
@override
|
||||||
Settings fromJson(Map<String, dynamic> json) {
|
Settings fromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
return _appendNewModules(Settings.fromJson(json));
|
return Settings.fromJson(json);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
try {
|
try {
|
||||||
return _appendNewModules(
|
return Settings.fromJson(
|
||||||
Settings.fromJson(
|
|
||||||
_mergeSettings(json, DefaultSettings.get().toJson()),
|
_mergeSettings(json, DefaultSettings.get().toJson()),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return DefaultSettings.get();
|
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
|
@override
|
||||||
Map<String, dynamic>? toJson(Settings state) => state.toJson();
|
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
|
/// @nodoc
|
||||||
mixin _$TimetableState {
|
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.
|
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
|
||||||
// Inclusive: weeks whose start is on/before `accessibleEndDate` and
|
// start is on/before `accessibleEndDate` and its end on/after
|
||||||
// whose end is on/after `accessibleStartDate` are within the user's
|
// `accessibleStartDate`. Null = that bound not discovered yet.
|
||||||
// permitted range. Null = no upper / lower bound discovered yet.
|
|
||||||
DateTime? get accessibleStartDate; DateTime? get accessibleEndDate;
|
DateTime? get accessibleStartDate; DateTime? get accessibleEndDate;
|
||||||
/// Create a copy of TimetableState
|
/// Create a copy of TimetableState
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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 startDate;
|
||||||
@override final DateTime endDate;
|
@override final DateTime endDate;
|
||||||
@override@JsonKey() final int dataVersion;
|
@override@JsonKey() final int dataVersion;
|
||||||
// Boundaries learned from past server denials of inaccessible weeks.
|
// Boundaries learned from past server denials. A week is permitted when its
|
||||||
// Inclusive: weeks whose start is on/before `accessibleEndDate` and
|
// start is on/before `accessibleEndDate` and its end on/after
|
||||||
// whose end is on/after `accessibleStartDate` are within the user's
|
// `accessibleStartDate`. Null = that bound not discovered yet.
|
||||||
// permitted range. Null = no upper / lower bound discovered yet.
|
|
||||||
@override final DateTime? accessibleStartDate;
|
@override final DateTime? accessibleStartDate;
|
||||||
@override final DateTime? accessibleEndDate;
|
@override final DateTime? accessibleEndDate;
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ Map<String, dynamic> _$ModulesSettingsToJson(
|
|||||||
|
|
||||||
const _$ModulesEnumMap = {
|
const _$ModulesEnumMap = {
|
||||||
Modules.timetable: 'timetable',
|
Modules.timetable: 'timetable',
|
||||||
|
Modules.ticker: 'ticker',
|
||||||
Modules.talk: 'talk',
|
Modules.talk: 'talk',
|
||||||
Modules.files: 'files',
|
Modules.files: 'files',
|
||||||
Modules.marianumMessage: 'marianumMessage',
|
Modules.marianumMessage: 'marianumMessage',
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ class _LoginCardState extends State<LoginCard> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
|
key: const Key('login-username-field'),
|
||||||
controller: _usernameController,
|
controller: _usernameController,
|
||||||
enabled: !loading,
|
enabled: !loading,
|
||||||
validator: _required,
|
validator: _required,
|
||||||
@@ -125,6 +126,7 @@ class _LoginCardState extends State<LoginCard> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
|
key: const Key('login-password-field'),
|
||||||
controller: _passwordController,
|
controller: _passwordController,
|
||||||
focusNode: _passwordFocus,
|
focusNode: _passwordFocus,
|
||||||
enabled: !loading,
|
enabled: !loading,
|
||||||
@@ -146,6 +148,7 @@ class _LoginCardState extends State<LoginCard> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 50,
|
height: 50,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
|
key: const Key('login-submit-button'),
|
||||||
onPressed: loading ? null : _submit,
|
onPressed: loading ? null : _submit,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class DefaultSettings {
|
|||||||
modulesSettings: ModulesSettings(
|
modulesSettings: ModulesSettings(
|
||||||
moduleOrder: [
|
moduleOrder: [
|
||||||
Modules.timetable,
|
Modules.timetable,
|
||||||
|
Modules.ticker,
|
||||||
Modules.talk,
|
Modules.talk,
|
||||||
Modules.files,
|
Modules.files,
|
||||||
Modules.marianumMessage,
|
Modules.marianumMessage,
|
||||||
|
|||||||
@@ -118,10 +118,19 @@ class ModuleSortBody extends StatelessWidget {
|
|||||||
.values
|
.values
|
||||||
.toList(),
|
.toList(),
|
||||||
onReorderItem: (oldIndex, newIndex) {
|
onReorderItem: (oldIndex, newIndex) {
|
||||||
var order = settings.val().modulesSettings.moduleOrder.toList();
|
final displayed = AppModule.modules(
|
||||||
final movedModule = order.removeAt(oldIndex);
|
context,
|
||||||
order.insert(newIndex, movedModule);
|
showFiltered: true,
|
||||||
settings.val(write: true).modulesSettings.moduleOrder = order;
|
).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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 671 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 219 KiB |
@@ -94,10 +94,16 @@ dependencies:
|
|||||||
# Opens the OS notification settings for this app (iOS + Android) when the
|
# Opens the OS notification settings for this app (iOS + Android) when the
|
||||||
# user declined the permission and wants to enable it later.
|
# user declined the permission and wants to enable it later.
|
||||||
app_settings: ^5.1.1
|
app_settings: ^5.1.1
|
||||||
|
flutter_layout_grid: ^2.0.8
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
# Screenshot-Automatisierung für den Play Store: treibt den Demo-Login über
|
||||||
|
# flutter drive und ruft binding.takeScreenshot pro Hauptscreen auf
|
||||||
|
# (integration_test/ + test_driver/, orchestriert von tool/screenshots.sh).
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
fake_async: ^1.3.1
|
fake_async: ^1.3.1
|
||||||
|
|
||||||
flutter_launcher_icons: ^0.14.3
|
flutter_launcher_icons: ^0.14.3
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:integration_test/integration_test_driver_extended.dart';
|
||||||
|
|
||||||
|
/// Host-seitiger Treiber für den Screenshot-Lauf. Schreibt jeden von
|
||||||
|
/// `binding.takeScreenshot(name)` gelieferten PNG-Frame nach
|
||||||
|
/// `$SCREENSHOT_OUT_DIR/<name>.png` (Default: ./screenshots). Das Zielverzeichnis
|
||||||
|
/// setzt tool/screenshots.sh pro Gerätegröße auf das passende Fastlane-Bildverzeichnis.
|
||||||
|
Future<void> main() async {
|
||||||
|
final outDir = Platform.environment['SCREENSHOT_OUT_DIR'] ?? 'screenshots';
|
||||||
|
|
||||||
|
await integrationDriver(
|
||||||
|
onScreenshot: (name, bytes, [args]) async {
|
||||||
|
final file = File('$outDir/$name.png');
|
||||||
|
await file.create(recursive: true);
|
||||||
|
await file.writeAsBytes(bytes);
|
||||||
|
stdout.writeln('Screenshot gespeichert: ${file.path}');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Store-Screenshots über den Demo-Login aufnehmen.
|
||||||
|
#
|
||||||
|
# Fährt den integration_test-Screenshot-Lauf (integration_test/screenshot_test.dart)
|
||||||
|
# über flutter drive für die drei von Google Play verlangten Formfaktoren durch
|
||||||
|
# (Phone + 7″ + 10″ Tablet sind inzwischen Pflicht). Statt drei AVDs zu verwalten,
|
||||||
|
# wird EIN laufendes Gerät/Emulator pro Durchlauf per `adb shell wm size/density`
|
||||||
|
# auf die Zielauflösung gebracht; takeScreenshot liefert dann Frames in genau
|
||||||
|
# dieser Pixelgröße.
|
||||||
|
#
|
||||||
|
# Ablage:
|
||||||
|
# - kanonisch (versioniert): materials/screenshots/android/<formfaktor>/
|
||||||
|
# - Kopie für den Upload: android/fastlane/metadata/android/de-DE/images/<...>Screenshots/
|
||||||
|
# Danach: `cd android && fastlane upload_screenshots`.
|
||||||
|
#
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - genau ein per `adb devices` sichtbares Gerät/Emulator (>= Android 8)
|
||||||
|
# - flutter, adb im PATH
|
||||||
|
#
|
||||||
|
# Aufruf (vom Client-Projektwurzelverzeichnis):
|
||||||
|
# tool/screenshots.sh # debug-Build (schnell)
|
||||||
|
# BUILD_MODE=profile tool/screenshots.sh
|
||||||
|
# PROFILES="phone" tool/screenshots.sh # nur eine Größe
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
BUILD_MODE="${BUILD_MODE:-debug}"
|
||||||
|
MATERIALS_BASE="materials/screenshots/android"
|
||||||
|
FASTLANE_BASE="android/fastlane/metadata/android/de-DE/images"
|
||||||
|
APP_ID="eu.mhsl.marianum.mobile.client"
|
||||||
|
|
||||||
|
# name | breite x höhe (px) | dichte (dpi) | materials-Ordner | fastlane-Ordner
|
||||||
|
# Phone nutzt die NATIVE Displayauflösung des Geräts (voller Hochformat, nicht
|
||||||
|
# auf 16:9 gestaucht). Tablets im QUERFORMAT (Breite > Höhe); bei den
|
||||||
|
# Landscape-Breiten (>1000dp) zeigt Talk zudem die Master-Detail-Splitview.
|
||||||
|
# Dichten so gewählt, dass die adaptive Bottom-Bar die passende Tab-Anzahl zeigt.
|
||||||
|
declare -A PROFILE_SIZE=(
|
||||||
|
[phone]="native" [seven]="1920x1200" [ten]="2560x1600"
|
||||||
|
)
|
||||||
|
declare -A PROFILE_DENSITY=(
|
||||||
|
[phone]="native" [seven]="240" [ten]="280"
|
||||||
|
)
|
||||||
|
declare -A PROFILE_MATERIALS=(
|
||||||
|
[phone]="phone" [seven]="7-inch" [ten]="10-inch"
|
||||||
|
)
|
||||||
|
declare -A PROFILE_FASTLANE=(
|
||||||
|
[phone]="phoneScreenshots"
|
||||||
|
[seven]="sevenInchScreenshots"
|
||||||
|
[ten]="tenInchScreenshots"
|
||||||
|
)
|
||||||
|
|
||||||
|
PROFILES="${PROFILES:-phone seven ten}"
|
||||||
|
|
||||||
|
# App-APK einmal vorab bauen, damit sie vor jedem `flutter drive` installiert
|
||||||
|
# und die Notification-Permission gegrantet werden kann (drive deinstalliert die
|
||||||
|
# App nach jedem Lauf, sonst wäre der Grant weg und ein Systemdialog könnte die
|
||||||
|
# Screenshots verdecken). Der eigentliche drive-Lauf reinstalliert sie per -r.
|
||||||
|
APK="build/app/outputs/flutter-apk/app-${BUILD_MODE}.apk"
|
||||||
|
echo "Baue App-APK ($BUILD_MODE) vorab…"
|
||||||
|
flutter build apk "--$BUILD_MODE"
|
||||||
|
|
||||||
|
reset_display() {
|
||||||
|
echo "Setze Displaygröße/-dichte zurück…"
|
||||||
|
adb shell wm size reset || true
|
||||||
|
adb shell wm density reset || true
|
||||||
|
}
|
||||||
|
trap reset_display EXIT
|
||||||
|
|
||||||
|
for profile in $PROFILES; do
|
||||||
|
size="${PROFILE_SIZE[$profile]}"
|
||||||
|
density="${PROFILE_DENSITY[$profile]}"
|
||||||
|
materials_dir="$MATERIALS_BASE/${PROFILE_MATERIALS[$profile]}"
|
||||||
|
fastlane_dir="$FASTLANE_BASE/${PROFILE_FASTLANE[$profile]}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Profil '$profile' ($size @ ${density}dpi) -> $materials_dir ==="
|
||||||
|
mkdir -p "$materials_dir" "$fastlane_dir"
|
||||||
|
rm -f "$materials_dir"/*.png "$fastlane_dir"/*.png
|
||||||
|
|
||||||
|
# "native" -> Geräteauflösung/-dichte verwenden (für Phone), sonst erzwingen.
|
||||||
|
if [ "$size" = "native" ]; then
|
||||||
|
adb shell wm size reset
|
||||||
|
adb shell wm density reset
|
||||||
|
else
|
||||||
|
adb shell wm size "$size"
|
||||||
|
adb shell wm density "$density"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# App sicherstellen, frische (ausgeloggte) Session erzwingen und
|
||||||
|
# Notification-Permission erteilen, bevor drive läuft. pm clear setzt auch die
|
||||||
|
# Runtime-Permissions zurück, daher der Grant DANACH.
|
||||||
|
[ -f "$APK" ] && adb install -r "$APK" >/dev/null 2>&1 || true
|
||||||
|
adb shell pm clear "$APP_ID" >/dev/null 2>&1 || true
|
||||||
|
adb shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
SCREENSHOT_OUT_DIR="$materials_dir" flutter drive \
|
||||||
|
--driver=test_driver/integration_test.dart \
|
||||||
|
--target=integration_test/screenshot_test.dart \
|
||||||
|
--"$BUILD_MODE" \
|
||||||
|
--no-dds
|
||||||
|
|
||||||
|
# Kanonische Bilder in den Fastlane-Upload-Ordner spiegeln.
|
||||||
|
cp "$materials_dir"/*.png "$fastlane_dir"/
|
||||||
|
echo "Profil '$profile' fertig: $(ls -1 "$materials_dir"/*.png 2>/dev/null | wc -l) Screenshots"
|
||||||
|
done
|
||||||
|
|
||||||
|
reset_display
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Kanonische Screenshots: $MATERIALS_BASE/{phone,7-inch,10-inch}/"
|
||||||
|
echo "Upload: cd android && fastlane upload_screenshots"
|
||||||