Files
Client/lib/view/pages/settings/widgets/push_status_sheet.dart
T

300 lines
9.8 KiB
Dart

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).
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>();
return 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<void> _openNotificationSettings() async {
_reloadOnResume = true;
await AppSettings.openAppSettings(type: AppSettingsType.notification);
}
Future<void> _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<void> _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<void> _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<Widget> _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,
semanticLabel: 'In Ordnung',
);
case PushCheck.fail:
return Icon(
Icons.cancel_outlined,
color: theme.colorScheme.error,
semanticLabel: 'Fehler',
);
case PushCheck.unknown:
return Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Unbekannt',
);
}
}
}