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 createState() => _EmergencyNoticeGateState(); } class _EmergencyNoticeGateState extends State 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 _check() async { if (_showing || !mounted) return; final url = context .read() .val() .devToolsSettings .resolveEmergencyNoticeUrl(); if (url == null) return; final notice = await _client.fetch(url); if (notice == null || !mounted || _showing) return; _showing = true; try { await showDialog( 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 _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, ), ), ], ), ), ), ), ); } }