implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching
This commit is contained in:
@@ -3,14 +3,12 @@ import 'dart:async';
|
||||
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 '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_registration_store.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../widgets/push_status_sheet.dart';
|
||||
|
||||
class TalkSection extends StatelessWidget {
|
||||
const TalkSection({super.key});
|
||||
@@ -56,7 +54,9 @@ class TalkSection extends StatelessWidget {
|
||||
Icon(Icons.notifications_active_outlined),
|
||||
),
|
||||
title: const Text('Push-Benachrichtigungen'),
|
||||
subtitle: const Text('Neue Talk-Nachrichten direkt aufs Gerät'),
|
||||
subtitle: const Text(
|
||||
'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
|
||||
),
|
||||
trailing: Checkbox(
|
||||
value: notificationSettings.enabled,
|
||||
onChanged: (e) {
|
||||
@@ -74,8 +74,9 @@ class TalkSection extends StatelessWidget {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Benachrichtigungen sind in den Systemeinstellungen '
|
||||
'deaktiviert — bitte dort erlauben.',
|
||||
'Die Benachrichtigungsberechtigung wurde in den '
|
||||
'Systemeinstellungen deaktiviert. Bitte aktiviere '
|
||||
'sie dort, um Push-Benachrichtigungen zu erhalten.',
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -87,102 +88,14 @@ class TalkSection extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
if (notificationSettings.enabled) const _TestNotificationTile(),
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Send a test notification" action, shown only while push is enabled. The
|
||||
/// button stays disabled until a registration is confirmed present, then calls
|
||||
/// the backend and reports the result via a SnackBar.
|
||||
class _TestNotificationTile extends StatefulWidget {
|
||||
const _TestNotificationTile();
|
||||
|
||||
@override
|
||||
State<_TestNotificationTile> createState() => _TestNotificationTileState();
|
||||
}
|
||||
|
||||
class _TestNotificationTileState extends State<_TestNotificationTile> {
|
||||
static const _permissionDeniedHint =
|
||||
'Benachrichtigungen sind in den Systemeinstellungen deaktiviert';
|
||||
|
||||
bool _registered = false;
|
||||
bool _permissionDenied = false;
|
||||
bool _sending = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadState();
|
||||
}
|
||||
|
||||
Future<void> _loadState() async {
|
||||
final registered = await const PushRegistrationStore().isRegistered();
|
||||
final denied = await PushRegistration.isOsPermissionDenied();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_registered = registered;
|
||||
_permissionDenied = denied;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendTest() async {
|
||||
if (_sending) return;
|
||||
Haptics.selection();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
// Re-check right before sending: the user may have flipped the OS
|
||||
// permission in the system settings since this tile was built.
|
||||
final denied = await PushRegistration.isOsPermissionDenied();
|
||||
if (!mounted) return;
|
||||
if (denied) {
|
||||
setState(() => _permissionDenied = true);
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('$_permissionDeniedHint — bitte dort erlauben.'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_permissionDenied = false;
|
||||
_sending = true;
|
||||
});
|
||||
String message;
|
||||
try {
|
||||
final devices = await PushDeviceTest().run();
|
||||
message = devices >= 1
|
||||
? 'Testbenachrichtigung an $devices Gerät(e) gesendet'
|
||||
: 'Kein Gerät registriert — Push-Registrierung prüfen';
|
||||
} on Object catch (e) {
|
||||
message = errorToUserMessage(e);
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _sending = false);
|
||||
messenger.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_registered) return const SizedBox.shrink();
|
||||
return ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.send_outlined)),
|
||||
title: const Text('Testbenachrichtigung senden'),
|
||||
subtitle: _permissionDenied
|
||||
? Text(
|
||||
_permissionDeniedHint,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
)
|
||||
: const Text('Prüft, ob Push auf diesem Gerät ankommt'),
|
||||
trailing: _sending
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.arrow_right),
|
||||
enabled: !_sending,
|
||||
onTap: _sending ? null : _sendTest,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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/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<SettingsCubit>();
|
||||
final capabilities = context.read<CapabilitiesCubit>();
|
||||
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> {
|
||||
PushStatusReport? _report;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
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 (_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 (_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) => ListTile(
|
||||
dense: true,
|
||||
leading: _stateIcon(row.state, theme),
|
||||
title: Text(row.label),
|
||||
subtitle: row.detail == null ? null : Text(row.detail!),
|
||||
),
|
||||
),
|
||||
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);
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user