implemented a backend-independent emergency notice system
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user