improved push notification settings with health indicators and optimized notification dismissal

This commit is contained in:
2026-07-16 22:40:33 +02:00
parent 8128cede21
commit e625216a90
5 changed files with 223 additions and 49 deletions
+7
View File
@@ -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
+23
View File
@@ -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<void>.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)
@@ -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<SettingsCubit>();
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<CapabilitiesCubit>(),
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<void> _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();
},
);
}
}
@@ -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<void> showPushStatusSheet(BuildContext context) {
// Captured here: the sheet outlives this build context's element tree.
final settings = context.read<SettingsCubit>();
final capabilities = context.read<CapabilitiesCubit>();
showDetailsBottomSheet(
return showDetailsBottomSheet(
context,
header: const ListTile(
leading: Icon(Icons.monitor_heart_outlined),
@@ -14,29 +14,39 @@ class SettingsCheckboxTile extends StatelessWidget {
final bool value;
final ValueChanged<bool> 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],
),
);
}
}