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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -55,6 +55,7 @@ import 'widget/avatar_disk_cache.dart';
|
|||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
import 'widget/downloads/download_tray.dart';
|
import 'widget/downloads/download_tray.dart';
|
||||||
|
import 'widget/emergency/emergency_notice_gate.dart';
|
||||||
import 'widget_data/widget_sync.dart';
|
import 'widget_data/widget_sync.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
@@ -360,9 +361,10 @@ class _MainState extends State<Main> {
|
|||||||
// would otherwise cover it).
|
// would otherwise cover it).
|
||||||
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
|
||||||
),
|
),
|
||||||
home: LoaderOverlay(
|
home: EmergencyNoticeGate(
|
||||||
child: Breaker(
|
child: LoaderOverlay(
|
||||||
breaker: BreakerArea.global,
|
child: Breaker(
|
||||||
|
breaker: BreakerArea.global,
|
||||||
child: BlocConsumer<AccountBloc, AccountState>(
|
child: BlocConsumer<AccountBloc, AccountState>(
|
||||||
listenWhen: (previous, current) =>
|
listenWhen: (previous, current) =>
|
||||||
previous.status != current.status,
|
previous.status != current.status,
|
||||||
@@ -468,6 +470,7 @@ class _MainState extends State<Main> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ class DevToolsSettings {
|
|||||||
@JsonKey(defaultValue: '')
|
@JsonKey(defaultValue: '')
|
||||||
String marianumConnectCustomUrl;
|
String marianumConnectCustomUrl;
|
||||||
|
|
||||||
|
/// Optional override for the backend-independent emergency-notice source.
|
||||||
|
/// Empty falls back to [emergencyNoticeDefaultUrl].
|
||||||
|
@JsonKey(defaultValue: '')
|
||||||
|
String emergencyNoticeUrl;
|
||||||
|
|
||||||
DevToolsSettings({
|
DevToolsSettings({
|
||||||
required this.showPerformanceOverlay,
|
required this.showPerformanceOverlay,
|
||||||
required this.checkerboardOffscreenLayers,
|
required this.checkerboardOffscreenLayers,
|
||||||
required this.checkerboardRasterCacheImages,
|
required this.checkerboardRasterCacheImages,
|
||||||
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
|
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
|
||||||
this.marianumConnectCustomUrl = '',
|
this.marianumConnectCustomUrl = '',
|
||||||
|
this.emergencyNoticeUrl = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolves the effective base URL, falling back to live when the custom URL
|
// 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 liveUrl = 'https://connect.marianum-fulda.de';
|
||||||
static const String betaUrl = 'https://connect-beta.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
|
/// `true` in builds where plaintext HTTP custom endpoints are still allowed
|
||||||
/// (debug, profile). Release builds keep this `false` and the picker
|
/// (debug, profile). Release builds keep this `false` and the picker
|
||||||
/// rejects `http://` entirely.
|
/// rejects `http://` entirely.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ DevToolsSettings _$DevToolsSettingsFromJson(
|
|||||||
) ??
|
) ??
|
||||||
MarianumConnectEndpoint.live,
|
MarianumConnectEndpoint.live,
|
||||||
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
|
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
|
||||||
|
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
||||||
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
|
|||||||
'marianumConnectEndpoint':
|
'marianumConnectEndpoint':
|
||||||
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
|
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
|
||||||
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
|
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
|
||||||
|
'emergencyNoticeUrl': instance.emergencyNoticeUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const _$MarianumConnectEndpointEnumMap = {
|
const _$MarianumConnectEndpointEnumMap = {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class DefaultSettings {
|
|||||||
showPerformanceOverlay: false,
|
showPerformanceOverlay: false,
|
||||||
marianumConnectEndpoint: MarianumConnectEndpoint.live,
|
marianumConnectEndpoint: MarianumConnectEndpoint.live,
|
||||||
marianumConnectCustomUrl: '',
|
marianumConnectCustomUrl: '',
|
||||||
|
emergencyNoticeUrl: '',
|
||||||
),
|
),
|
||||||
hapticSettings: HapticSettings(level: HapticLevel.full),
|
hapticSettings: HapticSettings(level: HapticLevel.full),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
|
|||||||
|
|
||||||
import '../../../../routing/app_routes.dart';
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
|
import '../../../../storage/dev_tools_settings.dart';
|
||||||
import '../../../../storage/settings.dart' as model;
|
import '../../../../storage/settings.dart' as model;
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
@@ -88,6 +89,27 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
|||||||
onTap: () =>
|
onTap: () =>
|
||||||
MarianumConnectEndpointPicker.show(context, widget.settings),
|
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(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.image_outlined)),
|
leading: const CenteredLeading(Icon(Icons.image_outlined)),
|
||||||
title: const Text('Thumb-storage'),
|
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.
|
# user declined the permission and wants to enable it later.
|
||||||
app_settings: ^7.0.0
|
app_settings: ^7.0.0
|
||||||
flutter_layout_grid: ^2.0.8
|
flutter_layout_grid: ^2.0.8
|
||||||
|
flutter_markdown_plus: ^1.0.12
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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