implemented a backend-independent emergency notice system

This commit is contained in:
2026-07-15 20:30:20 +02:00
parent e8c6ac1c65
commit 7c1f5c06df
11 changed files with 599 additions and 3 deletions
+116
View File
@@ -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;
}
}
}