From e625216a90e028c68d1ad371d022cf117416a84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Thu, 16 Jul 2026 22:40:33 +0200 Subject: [PATCH] improved push notification settings with health indicators and optimized notification dismissal --- lib/push/push_status.dart | 7 + lib/utils/downloads/download_manager.dart | 23 ++ .../pages/settings/sections/talk_section.dart | 214 ++++++++++++++---- .../settings/widgets/push_status_sheet.dart | 4 +- .../widgets/settings_checkbox_tile.dart | 24 +- 5 files changed, 223 insertions(+), 49 deletions(-) diff --git a/lib/push/push_status.dart b/lib/push/push_status.dart index 29098aa..011eaab 100644 --- a/lib/push/push_status.dart +++ b/lib/push/push_status.dart @@ -87,6 +87,13 @@ class PushStatusReport { (general.registeredProxyServer?.isNotEmpty ?? false) && !proxyEndpointMismatch(general) && general.lastRegistrationError == null; + + /// True when no link in the chain is currently broken. Unknown links stay + /// permissive (mirroring [readyForTestNotification]) so a not-yet-loaded + /// capability or an undetermined OS permission does not flip the at-a-glance + /// health icon to red. Drives the compact status indicator in the settings. + bool get chainHealthy => + buildPushStatusRows(this).every((row) => row.state != PushCheck.fail); } /// Collects the current push chain state. Settings/capability flags come from diff --git a/lib/utils/downloads/download_manager.dart b/lib/utils/downloads/download_manager.dart index 23284dc..7b3959a 100644 --- a/lib/utils/downloads/download_manager.dart +++ b/lib/utils/downloads/download_manager.dart @@ -179,6 +179,29 @@ class DownloadManager { final taskId = job.taskId; if (taskId == null || !Platform.isAndroid) return; final id = _androidNotificationId(taskId); + // background_downloader pushes the completion status to us *before* it posts + // the "Fertig" notification, and that post runs through a queue throttled to + // one notification per ~300ms. When the file opens immediately (lone + // foreground download), our first cancel therefore races ahead of the + // notification actually appearing and no-ops — leaving it stuck. Re-cancel + // across the throttle window so the notification is caught once it lands. + for (final delay in _dismissRetryDelays) { + if (delay == Duration.zero) { + _cancelNotification(id); + } else { + Future.delayed(delay, () => _cancelNotification(id)); + } + } + } + + static const _dismissRetryDelays = [ + Duration.zero, + Duration(milliseconds: 350), + Duration(milliseconds: 750), + Duration(milliseconds: 1500), + ]; + + void _cancelNotification(int id) { unawaited( NotificationService().flutterLocalNotificationsPlugin .cancel(id: id) diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index e6d5dd2..0a1a79b 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../push/push_registration.dart'; +import '../../../../push/push_status.dart'; import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../widget/centered_leading.dart'; import '../widgets/push_status_sheet.dart'; @@ -17,7 +19,6 @@ class TalkSection extends StatelessWidget { Widget build(BuildContext context) { final settings = context.watch(); final talkSettings = settings.val().talkSettings; - final notificationSettings = settings.val().notificationSettings; return Column( children: [ SettingsCheckboxTile( @@ -41,47 +42,180 @@ class TalkSection extends StatelessWidget { trailing: const Icon(Icons.arrow_right), onTap: () => AppRoutes.openChatBackgroundSettings(context), ), - SettingsCheckboxTile( - icon: Icons.notifications_active_outlined, - title: 'Push-Benachrichtigungen', - subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten', - value: notificationSettings.enabled, - onChanged: (enabled) { - settings.val(write: true).notificationSettings.enabled = enabled; - // Turning off does NOT unregister: the device stays subscribed so - // silent sync pushes keep arriving; the message handler and iOS - // NSE suppress only the visible notification (via the mirrored - // flag). Enabling (re-)registers and ensures the OS permission. - if (enabled) { - final messenger = ScaffoldMessenger.of(context); - unawaited(() async { - // Only register when the OS permission isn't explicitly - // denied — otherwise NC + proxy would push into the void. - if (await PushRegistration.requestOsPermission()) { - await PushRegistration().register(); - } else { - messenger.showSnackBar( - const SnackBar( - content: Text( - 'Die Benachrichtigungsberechtigung wurde in den ' - 'Systemeinstellungen deaktiviert. Bitte aktiviere ' - 'sie dort, um Push-Benachrichtigungen zu erhalten.', - ), - ), - ); - } - }()); - } - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)), - title: const Text('Push-Status'), - subtitle: const Text('Registrierung und Zustellung im Detail'), - trailing: const Icon(Icons.arrow_right), - onTap: () => showPushStatusSheet(context), + _PushSettings( + settings: settings, + capabilities: context.read(), + enabled: settings.val().notificationSettings.enabled, + devMode: settings.val().devToolsEnabled, ), ], ); } } + +/// The push area: the enable switch carries an at-a-glance health icon (green +/// check / red X) right before the checkbox, and the detailed status checklist +/// is hidden — it only surfaces when the chain is broken or the developer mode +/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail +/// entry share one source of truth. +class _PushSettings extends StatefulWidget { + final SettingsCubit settings; + final CapabilitiesCubit capabilities; + final bool enabled; + final bool devMode; + + const _PushSettings({ + required this.settings, + required this.capabilities, + required this.enabled, + required this.devMode, + }); + + @override + State<_PushSettings> createState() => _PushSettingsState(); +} + +class _PushSettingsState extends State<_PushSettings> + with WidgetsBindingObserver { + PushStatusReport? _report; + + /// True while a (de)registration triggered by the switch is in flight. The + /// report collected in that window still reflects the pre-registration state, + /// so the status is shown as "loading" instead of briefly flashing red. + bool _busy = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + unawaited(_load()); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didUpdateWidget(covariant _PushSettings oldWidget) { + super.didUpdateWidget(oldWidget); + // Toggling the setting changes several links at once — re-collect. + if (oldWidget.enabled != widget.enabled) unawaited(_load()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // The OS permission can change while the app is backgrounded. + if (state == AppLifecycleState.resumed) unawaited(_load()); + } + + Future _load() async { + final caps = widget.capabilities.state; + final report = await collectPushStatus( + settingEnabled: widget.settings.val().notificationSettings.enabled, + capabilityPush: caps.pushNotifications, + capabilitiesLoaded: caps.loaded, + ); + if (!mounted) return; + setState(() => _report = report); + } + + void _onToggle(bool enabled) { + widget.settings.val(write: true).notificationSettings.enabled = enabled; + // Turning off does NOT unregister: the device stays subscribed so silent + // sync pushes keep arriving; the message handler and iOS NSE suppress only + // the visible notification (via the mirrored flag). Enabling (re-)registers + // and ensures the OS permission. + if (!enabled) return; + setState(() => _busy = true); + final messenger = ScaffoldMessenger.of(context); + unawaited(() async { + try { + // Only register when the OS permission isn't explicitly denied — + // otherwise NC + proxy would push into the void. + if (await PushRegistration.requestOsPermission()) { + await PushRegistration().register(); + } else { + messenger.showSnackBar( + const SnackBar( + content: Text( + 'Die Benachrichtigungsberechtigung wurde in den ' + 'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um ' + 'Push-Benachrichtigungen zu erhalten.', + ), + ), + ); + } + } finally { + if (mounted) await _load(); + if (mounted) setState(() => _busy = false); + } + }()); + } + + @override + Widget build(BuildContext context) { + final report = _report; + final broken = + widget.enabled && !_busy && report != null && !report.chainHealthy; + return Column( + children: [ + SettingsCheckboxTile( + icon: Icons.notifications_active_outlined, + title: 'Push-Benachrichtigungen', + subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten', + value: widget.enabled, + beforeCheckbox: _inlineStatusIcon(report), + onChanged: _onToggle, + ), + // Detail entry only when there is a problem to fix or for developers. + if (broken || widget.devMode) _detailTile(error: broken), + ], + ); + } + + /// Health icon shown before the checkbox — a spinner while a registration is + /// in flight, otherwise the green/red verdict once the report has loaded. + Widget? _inlineStatusIcon(PushStatusReport? report) { + if (_busy) { + return const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } + if (!widget.enabled || report == null) return null; + final healthy = report.chainHealthy; + return Icon( + healthy ? Icons.check_circle : Icons.cancel, + color: healthy ? Colors.green : Theme.of(context).colorScheme.error, + semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht', + ); + } + + /// The full status checklist entry — same list-tile footprint whether broken + /// or not; a problem is signalled only through the error-colored icon/text. + Widget _detailTile({required bool error}) { + final color = error ? Theme.of(context).colorScheme.error : null; + final textStyle = color == null ? null : TextStyle(color: color); + return ListTile( + leading: CenteredLeading( + Icon(Icons.monitor_heart_outlined, color: color), + ), + title: Text('Push-Status', style: textStyle), + subtitle: Text( + error + ? 'Ein Schritt in der Zustellkette ist unterbrochen' + : 'Registrierung und Zustellung im Detail', + style: textStyle, + ), + trailing: Icon(Icons.arrow_right, color: color), + // The sheet can re-register; re-collect on close so the dot reflects it. + onTap: () async { + await showPushStatusSheet(context); + if (mounted) await _load(); + }, + ); + } +} diff --git a/lib/view/pages/settings/widgets/push_status_sheet.dart b/lib/view/pages/settings/widgets/push_status_sheet.dart index 6b1fc73..5f2ff76 100644 --- a/lib/view/pages/settings/widgets/push_status_sheet.dart +++ b/lib/view/pages/settings/widgets/push_status_sheet.dart @@ -20,11 +20,11 @@ import '../../../../widget/details_bottom_sheet.dart'; /// 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) { +Future showPushStatusSheet(BuildContext context) { // Captured here: the sheet outlives this build context's element tree. final settings = context.read(); final capabilities = context.read(); - showDetailsBottomSheet( + return showDetailsBottomSheet( context, header: const ListTile( leading: Icon(Icons.monitor_heart_outlined), diff --git a/lib/view/pages/settings/widgets/settings_checkbox_tile.dart b/lib/view/pages/settings/widgets/settings_checkbox_tile.dart index 724dca8..10dadfd 100644 --- a/lib/view/pages/settings/widgets/settings_checkbox_tile.dart +++ b/lib/view/pages/settings/widgets/settings_checkbox_tile.dart @@ -14,29 +14,39 @@ class SettingsCheckboxTile extends StatelessWidget { final bool value; final ValueChanged onChanged; + /// Optional widget rendered just before the checkbox (e.g. a status icon). + final Widget? beforeCheckbox; + const SettingsCheckboxTile({ required this.icon, required this.title, required this.value, required this.onChanged, this.subtitle, + this.beforeCheckbox, super.key, }); @override Widget build(BuildContext context) { final leadingIcon = Icon(icon); + final checkbox = Checkbox( + value: value, + onChanged: (e) { + Haptics.selection(); + onChanged(e ?? false); + }, + ); return ListTile( leading: subtitle == null ? leadingIcon : CenteredLeading(leadingIcon), title: Text(title), subtitle: subtitle == null ? null : Text(subtitle!), - trailing: Checkbox( - value: value, - onChanged: (e) { - Haptics.selection(); - onChanged(e ?? false); - }, - ), + trailing: beforeCheckbox == null + ? checkbox + : Row( + mainAxisSize: MainAxisSize.min, + children: [beforeCheckbox!, checkbox], + ), ); } }