Files
Client/lib/widget/emergency/emergency_notice_gate.dart
T

142 lines
4.0 KiB
Dart

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 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;
const EmergencyNoticeGate({required this.child, super.key});
@override
State<EmergencyNoticeGate> createState() => _EmergencyNoticeGateState();
}
class _EmergencyNoticeGateState extends State<EmergencyNoticeGate>
with WidgetsBindingObserver {
final EmergencyNoticeClient _client = 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,
),
),
],
),
),
),
),
);
}
}