implemented a backend-independent emergency notice system
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
---
|
||||
#
|
||||
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
|
||||
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
|
||||
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
|
||||
#
|
||||
# Notfall-Nachricht der MarianumMobile-App
|
||||
#
|
||||
# Diese Datei wird bei jedem App-Start geladen.
|
||||
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
|
||||
#
|
||||
# Steuerfelder:
|
||||
# active: true schaltet die Anzeige ein (Default: false)
|
||||
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
|
||||
# title: optionale Überschrift
|
||||
#
|
||||
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
|
||||
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
|
||||
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
|
||||
# Beispielkonfiguration:
|
||||
#
|
||||
# ---
|
||||
# # Ein Kommentar
|
||||
# active: true
|
||||
# dismissible: false
|
||||
# title: Wichtiger Hinweis
|
||||
# ---
|
||||
# Hinweistext in Markdown
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
active: false
|
||||
dismissible: true
|
||||
title: Hinweis
|
||||
---
|
||||
# Serverstörung
|
||||
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
|
||||
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
|
||||
|
||||
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
|
||||
@@ -0,0 +1,116 @@
|
||||
/// A backend-independent emergency notice, loaded from a foreign server URL.
|
||||
///
|
||||
/// Deliberately decoupled from MarianumConnect: if that backend is unreachable
|
||||
/// (the disaster case this exists for), a plain text file on any other host can
|
||||
/// still surface a message. The file is a small YAML-ish frontmatter followed
|
||||
/// by a free Markdown body, so it stays hand-writable in an emergency — no JSON
|
||||
/// escaping of the (multi-line) content.
|
||||
///
|
||||
/// See [parse] for the exact format.
|
||||
class EmergencyNotice {
|
||||
/// Whether the notice can be dismissed. `false` renders it full-screen and
|
||||
/// blocks back/barrier taps.
|
||||
final bool dismissible;
|
||||
|
||||
/// Optional heading shown above the body.
|
||||
final String? title;
|
||||
|
||||
/// Markdown body (the message itself).
|
||||
final String body;
|
||||
|
||||
const EmergencyNotice({
|
||||
required this.dismissible,
|
||||
required this.title,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
/// Parses the raw file into a displayable notice, or returns `null` when
|
||||
/// there is nothing to show. Never throws — any malformed input yields `null`
|
||||
/// so a broken file can never break the app.
|
||||
///
|
||||
/// Format:
|
||||
/// ```
|
||||
/// ---
|
||||
/// active: true
|
||||
/// # dismissible: false <- comment lines (# ...) are ignored
|
||||
/// dismissible: true
|
||||
/// title: Störung
|
||||
/// ---
|
||||
/// # Markdown heading
|
||||
/// Free **markdown** body.
|
||||
/// ```
|
||||
///
|
||||
/// Rules:
|
||||
/// - The frontmatter is everything between the first `---` line and the next
|
||||
/// `---` line. Both delimiters are required.
|
||||
/// - Frontmatter entries are `key: value`; a line whose trimmed form starts
|
||||
/// with `#` is a comment. Unknown keys are ignored.
|
||||
/// - `active` (default `false`) must be explicitly true, else `null`.
|
||||
/// - `dismissible` defaults to `true`.
|
||||
/// - `title` is optional. The rest after the closing `---` is the Markdown
|
||||
/// body; an empty body yields `null`.
|
||||
static EmergencyNotice? parse(String raw) {
|
||||
final lines = raw
|
||||
.replaceAll('\r\n', '\n')
|
||||
.replaceAll('\r', '\n')
|
||||
.split('\n');
|
||||
|
||||
// Locate the opening `---` (skipping any leading blank lines).
|
||||
var i = 0;
|
||||
while (i < lines.length && lines[i].trim().isEmpty) {
|
||||
i++;
|
||||
}
|
||||
if (i >= lines.length || lines[i].trim() != '---') return null;
|
||||
final openIndex = i;
|
||||
|
||||
// Locate the closing `---`.
|
||||
var closeIndex = -1;
|
||||
for (var j = openIndex + 1; j < lines.length; j++) {
|
||||
if (lines[j].trim() == '---') {
|
||||
closeIndex = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (closeIndex == -1) return null;
|
||||
|
||||
final meta = <String, String>{};
|
||||
for (var j = openIndex + 1; j < closeIndex; j++) {
|
||||
final line = lines[j].trim();
|
||||
if (line.isEmpty || line.startsWith('#')) continue;
|
||||
final sep = line.indexOf(':');
|
||||
if (sep <= 0) continue;
|
||||
final key = line.substring(0, sep).trim().toLowerCase();
|
||||
final value = line.substring(sep + 1).trim();
|
||||
meta[key] = value;
|
||||
}
|
||||
|
||||
if (_parseBool(meta['active']) != true) return null;
|
||||
|
||||
final body = lines.sublist(closeIndex + 1).join('\n').trim();
|
||||
if (body.isEmpty) return null;
|
||||
|
||||
final title = meta['title'];
|
||||
return EmergencyNotice(
|
||||
dismissible: _parseBool(meta['dismissible']) ?? true,
|
||||
title: (title == null || title.isEmpty) ? null : title,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
|
||||
static bool? _parseBool(String? value) {
|
||||
switch (value?.trim().toLowerCase()) {
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case '1':
|
||||
case 'on':
|
||||
return true;
|
||||
case 'false':
|
||||
case 'no':
|
||||
case '0':
|
||||
case 'off':
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'emergency_notice.dart';
|
||||
|
||||
/// Loads the emergency notice from a foreign URL over a standalone [Dio]
|
||||
/// instance — no MarianumConnect interceptors, base URL or auth. That keeps the
|
||||
/// fallback fully independent of the backend it is meant to survive.
|
||||
///
|
||||
/// Fail-safe by contract: if the server is unreachable, times out, answers with
|
||||
/// a non-2xx status or delivers unparsable content, [fetch] returns `null` and
|
||||
/// nothing is shown. It never throws.
|
||||
class EmergencyNoticeClient {
|
||||
const EmergencyNoticeClient();
|
||||
|
||||
Future<EmergencyNotice?> fetch(String url) async {
|
||||
try {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 5),
|
||||
sendTimeout: const Duration(seconds: 5),
|
||||
responseType: ResponseType.plain,
|
||||
),
|
||||
);
|
||||
final response = await dio.get<String>(url);
|
||||
final raw = response.data;
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return EmergencyNotice.parse(raw);
|
||||
} catch (_) {
|
||||
// Server down / timeout / bad status / malformed body: show nothing.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -55,6 +55,7 @@ import 'widget/avatar_disk_cache.dart';
|
||||
import 'widget/breaker/breaker.dart';
|
||||
import 'widget/debug/cache_view.dart';
|
||||
import 'widget/downloads/download_tray.dart';
|
||||
import 'widget/emergency/emergency_notice_gate.dart';
|
||||
import 'widget_data/widget_sync.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -360,7 +361,8 @@ class _MainState extends State<Main> {
|
||||
// would otherwise cover it).
|
||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||
),
|
||||
home: LoaderOverlay(
|
||||
home: EmergencyNoticeGate(
|
||||
child: LoaderOverlay(
|
||||
child: Breaker(
|
||||
breaker: BreakerArea.global,
|
||||
child: BlocConsumer<AccountBloc, AccountState>(
|
||||
@@ -471,6 +473,7 @@ class _MainState extends State<Main> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -17,12 +17,18 @@ class DevToolsSettings {
|
||||
@JsonKey(defaultValue: '')
|
||||
String marianumConnectCustomUrl;
|
||||
|
||||
/// Optional override for the backend-independent emergency-notice source.
|
||||
/// Empty falls back to [emergencyNoticeDefaultUrl].
|
||||
@JsonKey(defaultValue: '')
|
||||
String emergencyNoticeUrl;
|
||||
|
||||
DevToolsSettings({
|
||||
required this.showPerformanceOverlay,
|
||||
required this.checkerboardOffscreenLayers,
|
||||
required this.checkerboardRasterCacheImages,
|
||||
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
|
||||
this.marianumConnectCustomUrl = '',
|
||||
this.emergencyNoticeUrl = '',
|
||||
});
|
||||
|
||||
// Resolves the effective base URL, falling back to live when the custom URL
|
||||
@@ -43,6 +49,14 @@ class DevToolsSettings {
|
||||
static const String liveUrl = 'https://connect.marianum-fulda.de';
|
||||
static const String betaUrl = 'https://connect-beta.marianum-fulda.de';
|
||||
|
||||
/// Compiled-in source for the backend-independent emergency notice. Must live
|
||||
/// on infrastructure that is reachable even when MarianumConnect is down.
|
||||
static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override';
|
||||
|
||||
String? resolveEmergencyNoticeUrl() =>
|
||||
sanitizeCustomUrl(emergencyNoticeUrl) ??
|
||||
sanitizeCustomUrl(emergencyNoticeDefaultUrl);
|
||||
|
||||
/// `true` in builds where plaintext HTTP custom endpoints are still allowed
|
||||
/// (debug, profile). Release builds keep this `false` and the picker
|
||||
/// rejects `http://` entirely.
|
||||
|
||||
@@ -19,6 +19,7 @@ DevToolsSettings _$DevToolsSettingsFromJson(
|
||||
) ??
|
||||
MarianumConnectEndpoint.live,
|
||||
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
|
||||
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
||||
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
||||
'marianumConnectEndpoint':
|
||||
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
|
||||
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
|
||||
'emergencyNoticeUrl': instance.emergencyNoticeUrl,
|
||||
};
|
||||
|
||||
const _$MarianumConnectEndpointEnumMap = {
|
||||
|
||||
@@ -75,6 +75,7 @@ class DefaultSettings {
|
||||
showPerformanceOverlay: false,
|
||||
marianumConnectEndpoint: MarianumConnectEndpoint.live,
|
||||
marianumConnectCustomUrl: '',
|
||||
emergencyNoticeUrl: '',
|
||||
),
|
||||
hapticSettings: HapticSettings(level: HapticLevel.full),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/dev_tools_settings.dart';
|
||||
import '../../../../storage/settings.dart' as model;
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
@@ -88,6 +89,27 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
onTap: () =>
|
||||
MarianumConnectEndpointPicker.show(context, widget.settings),
|
||||
),
|
||||
BlocBuilder<SettingsCubit, model.Settings>(
|
||||
bloc: widget.settings,
|
||||
builder: (_, _) {
|
||||
final override = widget.settings
|
||||
.val()
|
||||
.devToolsSettings
|
||||
.emergencyNoticeUrl
|
||||
.trim();
|
||||
return ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.emergency_outlined)),
|
||||
title: const Text('Notfall-Nachricht (Quelle)'),
|
||||
subtitle: Text(
|
||||
override.isEmpty
|
||||
? 'Standardquelle (im Code hinterlegt)'
|
||||
: override,
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.image_outlined)),
|
||||
title: const Text('Thumb-storage'),
|
||||
@@ -170,3 +192,94 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Bottom-sheet editor for the backend-independent emergency-notice override
|
||||
/// URL. Empty clears the override so the compiled-in default source is used.
|
||||
class _EmergencyNoticeUrlEditor extends StatefulWidget {
|
||||
final SettingsCubit settings;
|
||||
const _EmergencyNoticeUrlEditor({required this.settings});
|
||||
|
||||
static void show(BuildContext context, SettingsCubit settings) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')),
|
||||
children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<_EmergencyNoticeUrlEditor> createState() =>
|
||||
_EmergencyNoticeUrlEditorState();
|
||||
}
|
||||
|
||||
class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
|
||||
late final TextEditingController _controller;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(
|
||||
text: widget.settings.val().devToolsSettings.emergencyNoticeUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _save() {
|
||||
final raw = _controller.text.trim();
|
||||
// Empty is valid: it clears the override and falls back to the default.
|
||||
if (raw.isNotEmpty &&
|
||||
DevToolsSettings.sanitizeCustomUrl(raw) == null) {
|
||||
setState(
|
||||
() => _error = DevToolsSettings.allowsHttpCustomEndpoint
|
||||
? 'Ungültige URL (http(s)://host[:port]/...)'
|
||||
: 'Ungültige URL — nur HTTPS erlaubt',
|
||||
);
|
||||
return;
|
||||
}
|
||||
widget.settings.val(write: true).devToolsSettings.emergencyNoticeUrl = raw;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Überschreibt die im Code hinterlegte Standardquelle. Leer '
|
||||
'lassen, um die Standardquelle zu verwenden.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'https://...',
|
||||
errorText: _error,
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_error != null) setState(() => _error = null);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: FilledButton(
|
||||
onPressed: _save,
|
||||
child: const Text('Übernehmen'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import '../../api/emergency/emergency_notice.dart';
|
||||
import '../../api/emergency/emergency_notice_client.dart';
|
||||
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
|
||||
/// Wraps the app and surfaces a backend-independent emergency notice on cold
|
||||
/// start and on resume. Transparent otherwise: renders [child] unchanged and
|
||||
/// only overlays a dialog when the foreign source has an active message.
|
||||
///
|
||||
/// Independent of MarianumConnect and of the login state by design — this is
|
||||
/// the disaster fallback for when the backend is gone. Any failure to load or
|
||||
/// parse is swallowed by [EmergencyNoticeClient], so a missing/broken source
|
||||
/// simply shows nothing.
|
||||
class EmergencyNoticeGate extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const EmergencyNoticeGate({required this.child, super.key});
|
||||
|
||||
@override
|
||||
State<EmergencyNoticeGate> createState() => _EmergencyNoticeGateState();
|
||||
}
|
||||
|
||||
class _EmergencyNoticeGateState extends State<EmergencyNoticeGate>
|
||||
with WidgetsBindingObserver {
|
||||
final EmergencyNoticeClient _client = const EmergencyNoticeClient();
|
||||
bool _showing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_check()));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) unawaited(_check());
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
if (_showing || !mounted) return;
|
||||
final url = context
|
||||
.read<SettingsCubit>()
|
||||
.val()
|
||||
.devToolsSettings
|
||||
.resolveEmergencyNoticeUrl();
|
||||
if (url == null) return;
|
||||
|
||||
final notice = await _client.fetch(url);
|
||||
if (notice == null || !mounted || _showing) return;
|
||||
|
||||
_showing = true;
|
||||
try {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: notice.dismissible,
|
||||
builder: (_) => _EmergencyNoticeDialog(notice: notice),
|
||||
);
|
||||
} finally {
|
||||
_showing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
class _EmergencyNoticeDialog extends StatelessWidget {
|
||||
final EmergencyNotice notice;
|
||||
|
||||
const _EmergencyNoticeDialog({required this.notice});
|
||||
|
||||
Future<void> _openLink(String? href) async {
|
||||
if (href == null) return;
|
||||
if (await canLaunchUrlString(href)) {
|
||||
await launchUrlString(href);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final body = MarkdownBody(
|
||||
data: notice.body,
|
||||
onTapLink: (_, href, _) => unawaited(_openLink(href)),
|
||||
);
|
||||
|
||||
if (notice.dismissible) {
|
||||
return AlertDialog(
|
||||
title: notice.title == null ? null : Text(notice.title!),
|
||||
content: SingleChildScrollView(child: body),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Schließen'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Full-screen, non-dismissible: no close affordance, back is blocked.
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Dialog.fullscreen(
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (notice.title != null) ...[
|
||||
Text(
|
||||
notice.title!,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ dependencies:
|
||||
# user declined the permission and wants to enable it later.
|
||||
app_settings: ^7.0.0
|
||||
flutter_layout_grid: ^2.0.8
|
||||
flutter_markdown_plus: ^1.0.12
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/api/emergency/emergency_notice.dart';
|
||||
|
||||
void main() {
|
||||
group('EmergencyNotice.parse', () {
|
||||
test('parses an active, dismissible notice', () {
|
||||
final notice = EmergencyNotice.parse('''
|
||||
---
|
||||
active: true
|
||||
dismissible: true
|
||||
title: Störung
|
||||
---
|
||||
# Ausfall
|
||||
Der Server ist **down**.
|
||||
''');
|
||||
|
||||
expect(notice, isNotNull);
|
||||
expect(notice!.dismissible, isTrue);
|
||||
expect(notice.title, 'Störung');
|
||||
expect(notice.body, '# Ausfall\nDer Server ist **down**.');
|
||||
});
|
||||
|
||||
test('parses a full-screen (dismissible: false) notice', () {
|
||||
final notice = EmergencyNotice.parse('''
|
||||
---
|
||||
active: true
|
||||
dismissible: false
|
||||
---
|
||||
Wichtige Meldung
|
||||
''');
|
||||
|
||||
expect(notice, isNotNull);
|
||||
expect(notice!.dismissible, isFalse);
|
||||
expect(notice.title, isNull);
|
||||
});
|
||||
|
||||
test('defaults dismissible to true when absent', () {
|
||||
final notice = EmergencyNotice.parse('''
|
||||
---
|
||||
active: true
|
||||
---
|
||||
Body
|
||||
''');
|
||||
|
||||
expect(notice!.dismissible, isTrue);
|
||||
});
|
||||
|
||||
test('returns null when active is false', () {
|
||||
expect(
|
||||
EmergencyNotice.parse('---\nactive: false\n---\nBody'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when active is missing', () {
|
||||
expect(
|
||||
EmergencyNotice.parse('---\ntitle: Hallo\n---\nBody'),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores comment lines in the frontmatter', () {
|
||||
final notice = EmergencyNotice.parse('''
|
||||
---
|
||||
# dismissible: false <- example config, disabled
|
||||
active: true
|
||||
dismissible: true
|
||||
---
|
||||
Body
|
||||
''');
|
||||
|
||||
expect(notice, isNotNull);
|
||||
expect(notice!.dismissible, isTrue);
|
||||
});
|
||||
|
||||
test('returns null without a frontmatter', () {
|
||||
expect(EmergencyNotice.parse('Just some **markdown** text'), isNull);
|
||||
});
|
||||
|
||||
test('returns null when the closing delimiter is missing', () {
|
||||
expect(EmergencyNotice.parse('---\nactive: true\nBody'), isNull);
|
||||
});
|
||||
|
||||
test('returns null on empty input', () {
|
||||
expect(EmergencyNotice.parse(''), isNull);
|
||||
expect(EmergencyNotice.parse(' \n '), isNull);
|
||||
});
|
||||
|
||||
test('returns null when the body is empty', () {
|
||||
expect(EmergencyNotice.parse('---\nactive: true\n---\n '), isNull);
|
||||
});
|
||||
|
||||
test('preserves the markdown body verbatim', () {
|
||||
final notice = EmergencyNotice.parse('''
|
||||
---
|
||||
active: true
|
||||
---
|
||||
# Titel
|
||||
|
||||
- Punkt 1
|
||||
- Punkt 2
|
||||
|
||||
Mehr [Info](https://example.org).
|
||||
''');
|
||||
|
||||
expect(
|
||||
notice!.body,
|
||||
'# Titel\n\n- Punkt 1\n- Punkt 2\n\nMehr [Info](https://example.org).',
|
||||
);
|
||||
});
|
||||
|
||||
test('handles CRLF line endings', () {
|
||||
final notice = EmergencyNotice.parse(
|
||||
'---\r\nactive: true\r\n---\r\nHallo\r\n',
|
||||
);
|
||||
|
||||
expect(notice, isNotNull);
|
||||
expect(notice!.body, 'Hallo');
|
||||
});
|
||||
|
||||
test('accepts alternative truthy values for active', () {
|
||||
expect(EmergencyNotice.parse('---\nactive: yes\n---\nB'), isNotNull);
|
||||
expect(EmergencyNotice.parse('---\nactive: 1\n---\nB'), isNotNull);
|
||||
expect(EmergencyNotice.parse('---\nactive: on\n---\nB'), isNotNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user