import 'dart:async'; import 'package:app_settings/app_settings.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../api/errors/error_mapper.dart'; import '../../../../api/marianumconnect/queries/push_device_test/push_device_test.dart'; import '../../../../extensions/date_time.dart'; import '../../../../push/push_registration.dart'; import '../../../../push/push_status.dart'; import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/app_progress_indicator.dart'; import '../../../../widget/demo_restricted.dart'; import '../../../../widget/details_bottom_sheet.dart'; /// Opens the push status checklist: one row per link in the push chain with /// an explanation where it is broken, the last registration attempt (incl. /// verbatim error), a manual re-register action and — once the chain is /// operational — a test notification. Loads once on open; the refresh action /// re-collects on demand (no polling). void showPushStatusSheet(BuildContext context) { // Captured here: the sheet outlives this build context's element tree. final settings = context.read(); final capabilities = context.read(); showDetailsBottomSheet( context, header: const ListTile( leading: Icon(Icons.monitor_heart_outlined), title: Text('Status der Push-Benachrichtigungen'), ), children: (sheetCtx) => [ _PushStatusBody(settings: settings, capabilities: capabilities), ], ); } class _PushStatusBody extends StatefulWidget { final SettingsCubit settings; final CapabilitiesCubit capabilities; const _PushStatusBody({required this.settings, required this.capabilities}); @override State<_PushStatusBody> createState() => _PushStatusBodyState(); } class _PushStatusBodyState extends State<_PushStatusBody> with WidgetsBindingObserver { PushStatusReport? _report; bool _busy = false; /// Set while the user is in the OS settings — the next `resumed` lifecycle /// event then re-collects the status so a just-changed permission shows up. bool _reloadOnResume = false; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _load(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed && _reloadOnResume) { _reloadOnResume = false; unawaited(_load()); } } Future _openNotificationSettings() async { _reloadOnResume = true; await AppSettings.openAppSettings(type: AppSettingsType.notification); } Future _load() async { final capabilitiesState = widget.capabilities.state; final report = await collectPushStatus( settingEnabled: widget.settings.val().notificationSettings.enabled, capabilityPush: capabilitiesState.pushNotifications, capabilitiesLoaded: capabilitiesState.loaded, ); if (!mounted) return; setState(() => _report = report); } Future _reRegister() async { if (guardDemoAction(context)) return; if (_busy) return; setState(() => _busy = true); final messenger = ScaffoldMessenger.of(context); final ok = await PushRegistration().register(); if (!mounted) return; setState(() => _busy = false); await _load(); messenger.showSnackBar( SnackBar( content: Text( ok ? 'Registrierung erfolgreich abgeschlossen' : 'Registrierung fehlgeschlagen — Details in der Statusübersicht', ), ), ); } Future _sendTest() async { if (guardDemoAction(context)) return; if (_busy) return; setState(() => _busy = true); final messenger = ScaffoldMessenger.of(context); String message; try { final devices = await PushDeviceTest().run(); message = switch (devices) { 0 => 'Es ist kein Gerät registriert — bitte erneut registrieren', 1 => 'Testbenachrichtigung an 1 Gerät gesendet', _ => 'Testbenachrichtigung an $devices Geräte gesendet', }; } on Object catch (e) { message = errorToUserMessage(e); } if (!mounted) return; setState(() => _busy = false); messenger.showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { final report = _report; if (report == null) { return const Padding( padding: EdgeInsets.all(32), child: Center(child: AppProgressIndicator.medium()), ); } final theme = Theme.of(context); final rows = buildPushStatusRows(report); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ...rows.map((row) { // Offer the OS-settings shortcut on the permission row whenever it // isn't granted — after a denial that's the only place to fix it. final canOpenSettings = row.opensNotificationSettings && row.state != PushCheck.ok; return ListTile( dense: true, leading: _stateIcon(row.state, theme), title: Text(row.label), subtitle: row.detail == null ? null : Text(row.detail!), trailing: canOpenSettings ? TextButton( onPressed: _openNotificationSettings, child: const Text('Einstellungen'), ) : null, ); }), if (report.general.lastRegistrationAt != null || report.talk.lastRegistrationAt != null) const Divider(height: 1), ..._lastAttempt(theme, 'Allgemein', report.general), ..._lastAttempt(theme, 'Talk', report.talk), Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: _actions(report), ), ], ); } /// Last-attempt line (+ verbatim error) for one registration type. List _lastAttempt( ThemeData theme, String label, PushTypeStatus status, ) { final at = status.lastRegistrationAt; if (at == null) return const []; final error = status.lastRegistrationError; return [ ListTile( dense: true, leading: Icon( error == null ? Icons.history : Icons.error_outline, color: error == null ? null : theme.colorScheme.error, ), title: Text( 'Letzte Registrierung ($label): ${at.formatDateTime()} — ' '${error == null ? 'erfolgreich' : 'fehlgeschlagen'}', ), ), if (error != null) Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), ), child: Text( error, style: TextStyle( fontFamily: 'monospace', fontSize: 12, color: theme.colorScheme.onSurfaceVariant, ), ), ), ), ]; } /// Action hierarchy: refresh stays a secondary icon action; the primary /// (filled) button is the test notification once the chain is operational, /// otherwise re-registering IS the primary next step and the test action is /// omitted (it could not succeed and the checklist explains why). Widget _actions(PushStatusReport report) { const spinner = SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ); final ready = report.readyForTestNotification; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ IconButton( onPressed: _busy ? null : _load, tooltip: 'Aktualisieren', icon: const Icon(Icons.refresh), ), // OverflowBar stacks the buttons vertically on narrow screens instead // of overflowing the row. Expanded( child: OverflowBar( alignment: MainAxisAlignment.end, overflowAlignment: OverflowBarAlignment.end, spacing: 8, overflowSpacing: 4, children: [ if (ready) ...[ TextButton( onPressed: _busy ? null : _reRegister, child: const Text('Erneut registrieren'), ), FilledButton.icon( onPressed: _busy ? null : _sendTest, icon: _busy ? spinner : const Icon(Icons.send_outlined), label: const Text('Testbenachrichtigung'), ), ] else FilledButton.icon( onPressed: _busy ? null : _reRegister, icon: _busy ? spinner : const Icon(Icons.sync), label: const Text('Erneut registrieren'), ), ], ), ), ], ); } Widget _stateIcon(PushCheck state, ThemeData theme) { switch (state) { case PushCheck.ok: return const Icon(Icons.check_circle_outline, color: Colors.green); case PushCheck.fail: return Icon(Icons.cancel_outlined, color: theme.colorScheme.error); case PushCheck.unknown: return Icon( Icons.remove_circle_outline, color: theme.colorScheme.onSurfaceVariant, ); } } }