merged develop
This commit is contained in:
@@ -212,14 +212,17 @@ class _SchoolAccountState extends State<_SchoolAccount> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName ?? username,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
displayName ?? username,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (displayName != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
@@ -304,11 +307,14 @@ class _AccountName extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hydrated_bloc/hydrated_bloc.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../../../api/cache_store.dart';
|
||||
import '../../../../push/chat_thread_store.dart';
|
||||
import '../../../../push/nid_store.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../storage/dev_tools_settings.dart';
|
||||
import '../../../../storage/settings.dart' as model;
|
||||
import '../../../../utils/directory_size.dart';
|
||||
import '../../../../utils/downloads/download_manager.dart';
|
||||
import '../../../../widget/avatar_disk_cache.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/debug/cache_view.dart';
|
||||
import '../../../../widget/debug/json_viewer.dart';
|
||||
import '../../../../widget/debug/bloc_storage_view.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
import '../widgets/endpoint_picker.dart';
|
||||
import '../widgets/settings_checkbox_tile.dart';
|
||||
import '../widgets/settings_dropdown_tile.dart';
|
||||
|
||||
class DevToolsSection extends StatefulWidget {
|
||||
final SettingsCubit settings;
|
||||
@@ -27,9 +38,16 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
|
||||
title: const Text('Diagnose'),
|
||||
subtitle: const Text('Version, Server, Sitzung, Push'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => AppRoutes.openDiagnostics(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.speed_outlined)),
|
||||
title: const Text('Performance overlays'),
|
||||
title: const Text('Debug-Overlays'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () {
|
||||
showDetailsBottomSheet(
|
||||
@@ -39,35 +57,57 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
bloc: widget.settings,
|
||||
builder: (_, _) {
|
||||
final dev = widget.settings.val().devToolsSettings;
|
||||
DevToolsSettings write() =>
|
||||
widget.settings.val(write: true).devToolsSettings;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.auto_graph_outlined,
|
||||
title: 'Performance graph',
|
||||
title: 'Performance-Graph',
|
||||
value: dev.showPerformanceOverlay,
|
||||
onChanged: (e) => widget.settings
|
||||
.val(write: true)
|
||||
.devToolsSettings
|
||||
.showPerformanceOverlay = e,
|
||||
onChanged: (e) => write().showPerformanceOverlay = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.screen_search_desktop_outlined,
|
||||
title: 'Indicate offscreen layers',
|
||||
value: dev.checkerboardOffscreenLayers,
|
||||
onChanged: (e) => widget.settings
|
||||
.val(write: true)
|
||||
.devToolsSettings
|
||||
.checkerboardOffscreenLayers = e,
|
||||
icon: Icons.timer_outlined,
|
||||
title: 'Frame-Statistik',
|
||||
subtitle: 'Build-/Raster-Zeiten und Jank-Zähler',
|
||||
value: dev.showFrameStats,
|
||||
onChanged: (e) => write().showFrameStats = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.imagesearch_roller_outlined,
|
||||
title: 'Indicate raster cache images',
|
||||
value: dev.checkerboardRasterCacheImages,
|
||||
onChanged: (e) => widget.settings
|
||||
.val(write: true)
|
||||
.devToolsSettings
|
||||
.checkerboardRasterCacheImages = e,
|
||||
icon: Icons.memory_outlined,
|
||||
title: 'Speicher-Statistik',
|
||||
subtitle: 'Prozess-RAM und Bild-Cache',
|
||||
value: dev.showMemoryStats,
|
||||
onChanged: (e) => write().showMemoryStats = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.slow_motion_video_outlined,
|
||||
title: 'Langsame Animationen',
|
||||
subtitle: '5× verlangsamt',
|
||||
value: dev.slowAnimations,
|
||||
onChanged: (e) => write().slowAnimations = e,
|
||||
),
|
||||
SettingsCheckboxTile(
|
||||
icon: Icons.accessibility_new_outlined,
|
||||
title: 'Semantics-Debugger',
|
||||
subtitle: 'Zeigt die Screenreader-Sicht',
|
||||
value: dev.showSemanticsDebugger,
|
||||
onChanged: (e) => write().showSemanticsDebugger = e,
|
||||
),
|
||||
SettingsDropdownTile<double>(
|
||||
icon: Icons.format_size_outlined,
|
||||
title: 'Textgröße',
|
||||
value: dev.textScaleOverride,
|
||||
options: const [0, 0.85, 1, 1.3, 1.6, 2],
|
||||
optionIcon: (e) => e == 0
|
||||
? Icons.phone_android_outlined
|
||||
: Icons.text_fields_outlined,
|
||||
optionLabel: (e) => e == 0
|
||||
? 'System'
|
||||
: '${e == e.roundToDouble() ? e.toInt() : e}×',
|
||||
onChanged: (e) => write().textScaleOverride = e,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -99,98 +139,204 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
.trim();
|
||||
return ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.emergency_outlined)),
|
||||
title: const Text('Notfall-Nachricht (Quelle)'),
|
||||
subtitle: Text(
|
||||
override.isEmpty
|
||||
? 'Standardquelle (im Code hinterlegt)'
|
||||
: override,
|
||||
),
|
||||
title: const Text('Notfall-Nachrichtenquelle'),
|
||||
subtitle: Text(override.isEmpty ? 'Standardquelle' : override),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings),
|
||||
onTap: () =>
|
||||
_EmergencyNoticeUrlEditor.show(context, widget.settings),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.image_outlined)),
|
||||
title: const Text('Thumb-storage'),
|
||||
subtitle: Text(
|
||||
'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen',
|
||||
const Divider(),
|
||||
_StorageTile(
|
||||
icon: Icons.settings_applications_outlined,
|
||||
title: 'Einstellungen',
|
||||
describe: () async => filesize(
|
||||
utf8.encode(jsonEncode(widget.settings.val().toJson())).length,
|
||||
),
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'Thumbs cache löschen',
|
||||
content: 'Alle zwischengespeicherten Bilder werden gelöscht.',
|
||||
confirmButton: 'Unwiederruflich löschen',
|
||||
onConfirm: () => PaintingBinding.instance.imageCache.clear(),
|
||||
).asDialog(context);
|
||||
},
|
||||
onOpen: (_) async => AppRoutes.openJsonViewer(
|
||||
context,
|
||||
widget.settings.val().toJson(),
|
||||
title: 'Einstellungen',
|
||||
),
|
||||
clearTitle: 'Einstellungen zurücksetzen',
|
||||
clearContent:
|
||||
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten '
|
||||
'sind nicht betroffen.',
|
||||
onClear: widget.settings.reset,
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(
|
||||
Icon(Icons.settings_applications_outlined),
|
||||
),
|
||||
title: const Text('Settings-storage JSON dump'),
|
||||
subtitle: Text(
|
||||
'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen',
|
||||
),
|
||||
onTap: () {
|
||||
JsonViewer.asDialog(context, widget.settings.val().toJson());
|
||||
_StorageTile(
|
||||
icon: Icons.account_tree_outlined,
|
||||
title: 'BLoC-Speicher',
|
||||
describe: () async {
|
||||
final keys = BlocStorageView.keys();
|
||||
return keys == null ? 'nicht auflistbar' : '${keys.length} Einträge';
|
||||
},
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'Einstellungen löschen',
|
||||
content:
|
||||
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.',
|
||||
confirmButton: 'Unwiederruflich Löschen',
|
||||
onConfirm: () {
|
||||
context.read<SettingsCubit>().reset();
|
||||
},
|
||||
).asDialog(context);
|
||||
},
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onOpen: AppRoutes.openBlocStorageView,
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.data_object)),
|
||||
title: const Text('Cache-storage JSON dump'),
|
||||
subtitle: FutureBuilder(
|
||||
future: const CacheView().totalSize(),
|
||||
builder: (context, snapshot) => Text(
|
||||
"etwa ${snapshot.hasError
|
||||
? "?"
|
||||
: snapshot.hasData
|
||||
? filesize(snapshot.data)
|
||||
: "..."}\nLange tippen um zu löschen",
|
||||
_StorageTile(
|
||||
icon: Icons.data_object,
|
||||
title: 'Request-Cache',
|
||||
describe: () async => filesize(await CacheStore.instance.totalSize()),
|
||||
onOpen: AppRoutes.openCacheView,
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.image_outlined,
|
||||
title: 'Bild-Speicher (RAM)',
|
||||
describe: () async {
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
return '${cache.currentSize} Bilder · '
|
||||
'${filesize(cache.currentSizeBytes)} von '
|
||||
'${filesize(cache.maximumSizeBytes)}';
|
||||
},
|
||||
clearTitle: 'Bild-Speicher leeren',
|
||||
clearContent:
|
||||
'Alle dekodierten Bilder im Arbeitsspeicher werden verworfen.',
|
||||
onClear: () async => PaintingBinding.instance.imageCache.clear(),
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.photo_library_outlined,
|
||||
title: 'Netzwerk-Bilder',
|
||||
describe: () async => filesize(
|
||||
await directorySize(
|
||||
Directory(
|
||||
'${(await getTemporaryDirectory()).path}/${DefaultCacheManager.key}',
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () => AppRoutes.openCacheView(context),
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'App-Cache löschen',
|
||||
content:
|
||||
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
|
||||
confirmButton: 'Unwiederruflich löschen',
|
||||
onConfirm: () =>
|
||||
const CacheView().clear().then((value) => setState(() {})),
|
||||
).asDialog(context);
|
||||
},
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
clearTitle: 'Netzwerk-Bilder löschen',
|
||||
clearContent:
|
||||
'Heruntergeladene Chat- und Vorschaubilder werden gelöscht und bei '
|
||||
'Bedarf neu geladen.',
|
||||
onClear: DefaultCacheManager().emptyCache,
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.data_object)),
|
||||
title: const Text('BLOC-storage state cache'),
|
||||
subtitle: const Text('Lange tippen um zu löschen'),
|
||||
onLongPress: () {
|
||||
ConfirmDialog(
|
||||
title: 'BLOC-Cache löschen',
|
||||
content:
|
||||
'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut',
|
||||
confirmButton: 'Unwiederruflich löschen',
|
||||
onConfirm: () => HydratedBloc.storage.clear(),
|
||||
).asDialog(context);
|
||||
_StorageTile(
|
||||
icon: Icons.account_circle_outlined,
|
||||
title: 'Avatar-Cache',
|
||||
describe: () async =>
|
||||
filesize(await AvatarDiskCache.instance.totalSize()),
|
||||
clearTitle: 'Avatar-Cache löschen',
|
||||
clearContent: 'Profilbilder werden bei Bedarf neu geladen.',
|
||||
onClear: () async {
|
||||
invalidateAvatarCache();
|
||||
await AvatarDiskCache.instance.clear();
|
||||
},
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.notifications_outlined,
|
||||
title: 'Push-Zuordnungen',
|
||||
describe: () async =>
|
||||
'${(await NidStore().all()).length} Benachrichtigungen zugeordnet',
|
||||
clearTitle: 'Push-Zuordnungen löschen',
|
||||
clearContent:
|
||||
'Die Zuordnung angezeigter Benachrichtigungen zu Chats und der '
|
||||
'gruppierte Chat-Verlauf gehen verloren. Bereits angezeigte '
|
||||
'Benachrichtigungen werden danach nicht mehr automatisch entfernt.',
|
||||
onClear: () async {
|
||||
await NidStore().clear();
|
||||
await ChatThreadStore().clearAll();
|
||||
},
|
||||
),
|
||||
_StorageTile(
|
||||
icon: Icons.download_outlined,
|
||||
title: 'Downloads',
|
||||
describe: () async =>
|
||||
filesize(await directorySize(await _downloadsDir())),
|
||||
clearTitle: 'Downloads löschen',
|
||||
clearContent:
|
||||
'Laufende Downloads werden abgebrochen und heruntergeladene '
|
||||
'Dateien gelöscht.',
|
||||
onClear: () async {
|
||||
DownloadManager.instance.clearAll();
|
||||
final dir = await _downloadsDir();
|
||||
if (!dir.existsSync()) return;
|
||||
// Cancelled tasks can still be flushing into the folder, so deleting
|
||||
// it as a whole races them; whatever survives shows up in the size.
|
||||
await for (final entity in dir.list()) {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} on FileSystemException {
|
||||
// Retried on the next clear.
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
static Future<Directory> _downloadsDir() async =>
|
||||
Directory('${(await getTemporaryDirectory()).path}/downloads');
|
||||
}
|
||||
|
||||
/// Storage row: tap opens a detail view ([onOpen]), the trailing button clears
|
||||
/// it after confirmation ([onClear]). The subtitle re-evaluates [describe]
|
||||
/// after either.
|
||||
class _StorageTile extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final Future<String> Function() describe;
|
||||
final Future<void> Function(BuildContext context)? onOpen;
|
||||
final String? clearTitle;
|
||||
final String? clearContent;
|
||||
final Future<void> Function()? onClear;
|
||||
|
||||
const _StorageTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.describe,
|
||||
this.onOpen,
|
||||
this.clearTitle,
|
||||
this.clearContent,
|
||||
this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_StorageTile> createState() => _StorageTileState();
|
||||
}
|
||||
|
||||
class _StorageTileState extends State<_StorageTile> {
|
||||
late Future<String> _description = widget.describe();
|
||||
|
||||
void _refresh() => setState(() {
|
||||
_description = widget.describe();
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final onOpen = widget.onOpen;
|
||||
final onClear = widget.onClear;
|
||||
return ListTile(
|
||||
leading: CenteredLeading(Icon(widget.icon)),
|
||||
title: Text(widget.title),
|
||||
subtitle: FutureBuilder(
|
||||
future: _description,
|
||||
builder: (context, snapshot) =>
|
||||
Text(snapshot.hasError ? '?' : snapshot.data ?? '…'),
|
||||
),
|
||||
onTap: onOpen == null
|
||||
? null
|
||||
: () async {
|
||||
await onOpen(context);
|
||||
if (mounted) _refresh();
|
||||
},
|
||||
trailing: onClear == null
|
||||
? const Icon(Icons.arrow_right)
|
||||
: IconButton(
|
||||
tooltip: 'Löschen',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => ConfirmDialog(
|
||||
title: widget.clearTitle ?? '${widget.title} löschen',
|
||||
content: widget.clearContent ?? '',
|
||||
confirmButton: 'Löschen',
|
||||
errorBuilder: (e) => '$e',
|
||||
onConfirmAsync: () async {
|
||||
await onClear();
|
||||
if (mounted) _refresh();
|
||||
},
|
||||
).asDialog(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom-sheet editor for the backend-independent emergency-notice override
|
||||
@@ -202,7 +348,7 @@ class _EmergencyNoticeUrlEditor extends StatefulWidget {
|
||||
static void show(BuildContext context, SettingsCubit settings) {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')),
|
||||
header: const ListTile(title: Text('Notfall-Nachrichtenquelle')),
|
||||
children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)],
|
||||
);
|
||||
}
|
||||
@@ -233,8 +379,7 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
|
||||
void _save() {
|
||||
final raw = _controller.text.trim();
|
||||
// Empty is valid: it clears the override and falls back to the default.
|
||||
if (raw.isNotEmpty &&
|
||||
DevToolsSettings.sanitizeCustomUrl(raw) == null) {
|
||||
if (raw.isNotEmpty && DevToolsSettings.sanitizeCustomUrl(raw) == null) {
|
||||
setState(
|
||||
() => _error = DevToolsSettings.allowsHttpCustomEndpoint
|
||||
? 'Ungültige URL (http(s)://host[:port]/...)'
|
||||
@@ -253,19 +398,11 @@ class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'Überschreibt die im Code hinterlegte Standardquelle. Leer '
|
||||
'lassen, um die Standardquelle zu verwenden.',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'https://...',
|
||||
labelText: 'Leer für Standard',
|
||||
errorText: _error,
|
||||
),
|
||||
onChanged: (_) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
|
||||
import '../../../../push/push_registration.dart';
|
||||
import '../../../../push/push_status.dart';
|
||||
import '../../../../session/session_manager.dart';
|
||||
@@ -104,6 +105,8 @@ class _PushSettingsState extends State<_PushSettings>
|
||||
|
||||
void _onToggle(bool enabled) {
|
||||
widget.settings.val(write: true).notificationSettings.enabled = enabled;
|
||||
// A permission granted below arrives via the resume heartbeat.
|
||||
TelemetryHeartbeat.report(notificationsEnabled: 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
|
||||
@@ -113,11 +116,10 @@ class _PushSettingsState extends State<_PushSettings>
|
||||
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 {
|
||||
final granted = await PushRegistration.requestOsPermission();
|
||||
// Registered regardless: silent sync pushes need no permission.
|
||||
await PushRegistration().register();
|
||||
if (!granted) {
|
||||
messenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
|
||||
Reference in New Issue
Block a user