diff --git a/lib/api/emergency/emergency_notice.dart b/lib/api/emergency/emergency_notice.dart index ad602f3..bc90104 100644 --- a/lib/api/emergency/emergency_notice.dart +++ b/lib/api/emergency/emergency_notice.dart @@ -1,21 +1,10 @@ -/// 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. +/// A backend-independent emergency notice loaded from a foreign server URL — +/// frontmatter (control fields) plus a free Markdown body, so it stays +/// hand-writable in an outage. See [parse] for the format. class EmergencyNotice { - /// Whether the notice can be dismissed. `false` renders it full-screen and - /// blocks back/barrier taps. + /// `false` renders 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({ @@ -24,38 +13,24 @@ class EmergencyNotice { 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. + /// Parses the raw file, or returns `null` when there is nothing to show. + /// Never throws — malformed input yields `null` so a broken file can't break + /// the app. /// - /// Format: /// ``` /// --- - /// active: true - /// # dismissible: false <- comment lines (# ...) are ignored - /// dismissible: true - /// title: Störung + /// active: true # required truthy, else null; # lines are comments + /// dismissible: true # default true + /// title: Störung # optional /// --- - /// # Markdown heading - /// Free **markdown** body. + /// Free **markdown** body (everything after the closing ---). /// ``` - /// - /// 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++; @@ -63,7 +38,6 @@ class EmergencyNotice { 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() == '---') { diff --git a/lib/api/emergency/emergency_notice_client.dart b/lib/api/emergency/emergency_notice_client.dart index 97f0ed3..e49379f 100644 --- a/lib/api/emergency/emergency_notice_client.dart +++ b/lib/api/emergency/emergency_notice_client.dart @@ -2,17 +2,30 @@ 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. +/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no +/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage. +/// Never throws: any failure yields `null` (nothing shown). /// -/// 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. +/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host; +/// it lives only for the process, so a cold start always fetches fresh. class EmergencyNoticeClient { - const EmergencyNoticeClient(); + EmergencyNoticeClient(); + + static const Duration cacheTtl = Duration(minutes: 1); + + EmergencyNotice? _cached; + String? _cachedUrl; + DateTime? _cachedAt; Future fetch(String url) async { + final cachedAt = _cachedAt; + if (cachedAt != null && + _cachedUrl == url && + DateTime.now().difference(cachedAt) < cacheTtl) { + return _cached; + } + + EmergencyNotice? result; try { final dio = Dio( BaseOptions( @@ -24,11 +37,15 @@ class EmergencyNoticeClient { ); final response = await dio.get(url); final raw = response.data; - if (raw == null || raw.isEmpty) return null; - return EmergencyNotice.parse(raw); + result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw); } catch (_) { - // Server down / timeout / bad status / malformed body: show nothing. - return null; + result = null; } + + // Cache failures too, so a down server isn't retried on every resume. + _cached = result; + _cachedUrl = url; + _cachedAt = DateTime.now(); + return result; } } diff --git a/lib/widget/emergency/emergency_notice_gate.dart b/lib/widget/emergency/emergency_notice_gate.dart index 44005c1..527fc7c 100644 --- a/lib/widget/emergency/emergency_notice_gate.dart +++ b/lib/widget/emergency/emergency_notice_gate.dart @@ -10,13 +10,9 @@ 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. +/// start and resume. Renders [child] unchanged and only overlays a dialog when +/// the foreign source has an active message. Independent of MarianumConnect and +/// the login state by design — the fallback for when the backend is gone. class EmergencyNoticeGate extends StatefulWidget { final Widget child; @@ -28,7 +24,7 @@ class EmergencyNoticeGate extends StatefulWidget { class _EmergencyNoticeGateState extends State with WidgetsBindingObserver { - final EmergencyNoticeClient _client = const EmergencyNoticeClient(); + final EmergencyNoticeClient _client = EmergencyNoticeClient(); bool _showing = false; @override