reworked developer tools with diagnostics, storage viewers and release-capable debug overlays

This commit is contained in:
2026-09-26 14:34:47 +02:00
parent 0f6c388940
commit e6f74ca6fe
18 changed files with 1330 additions and 239 deletions
@@ -70,8 +70,6 @@ class DefaultSettings {
fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS),
notificationSettings: NotificationSettings(enabled: true),
devToolsSettings: DevToolsSettings(
checkerboardOffscreenLayers: false,
checkerboardRasterCacheImages: false,
showPerformanceOverlay: false,
marianumConnectEndpoint: MarianumConnectEndpoint.live,
marianumConnectCustomUrl: '',
@@ -0,0 +1,222 @@
import 'dart:async';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../api/marianumconnect/auth/device_token_name.dart';
import '../../../api/marianumconnect/auth/token_storage.dart';
import '../../../api/marianumconnect/marianumconnect_endpoint.dart';
import '../../../api/marianumconnect/queries/telemetry_heartbeat/telemetry_device_id.dart';
import '../../../background/widget_background_task.dart';
import '../../../extensions/date_time.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../../push/push_registration_store.dart';
import '../../../push/push_registration_type.dart';
import '../../../state/app/modules/account/bloc/account_bloc.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../utils/clipboard_helper.dart';
import '../../../widget/app_progress_indicator.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget_data/widget_sync.dart';
import 'widgets/push_status_sheet.dart';
typedef _Row = ({String label, String value});
typedef _Section = ({String title, List<_Row> rows});
/// Read-only snapshot of build, endpoint, session, push and widget state for
/// bug reports. Secrets (passwords, bearer token) are never shown.
class DiagnosticsPage extends StatefulWidget {
const DiagnosticsPage({super.key});
@override
State<DiagnosticsPage> createState() => _DiagnosticsPageState();
}
class _DiagnosticsPageState extends State<DiagnosticsPage> {
late Future<List<_Section>> _sections = _collect();
Future<List<_Section>> _collect() async {
final settings = context.read<SettingsCubit>().val();
final accountStatus = context.read<AccountBloc>().state.status;
final account = AccountData();
const tokens = MarianumConnectTokenStorage();
final push = PushRegistrationStore();
Future<String> safe(Future<Object?> Function() read) async {
try {
final value = await read().timeout(const Duration(seconds: 5));
return switch (value) {
null => '–',
bool() => value ? 'ja' : 'nein',
DateTime() => '${value.formatDateTime()} (${value.formatRelative()})',
_ => '$value',
};
} on Object catch (e) {
return 'Fehler: $e';
}
}
Future<List<_Row>> rows(Map<String, Future<Object?> Function()> reads) =>
Future.wait(
reads.entries.map(
(e) async => (label: e.key, value: await safe(e.value)),
),
);
final info = await PackageInfo.fromPlatform();
return [
(
title: 'App',
rows: await rows({
'Version': () async => '${info.version} (${info.buildNumber})',
'Paket': () async => info.packageName,
'Build-Modus': () async => kReleaseMode
? 'Release'
: kProfileMode
? 'Profile'
: 'Debug',
'Plattform': () async =>
'${Platform.operatingSystem} ${Platform.operatingSystemVersion}',
'Gerät': DeviceTokenName.resolve,
'Dart': () async => Platform.version.split(' ').first,
}),
),
(
title: 'Server',
rows: await rows({
'Marianum Connect': () async => MarianumConnectEndpoint.current(),
'Nextcloud': () async =>
'https://${EndpointData().nextcloud().full()}',
'Notfall-Nachricht': () async =>
settings.devToolsSettings.resolveEmergencyNoticeUrl(),
}),
),
(
title: 'Sitzung',
rows: await rows({
'Status': () async => accountStatus.name,
'Benutzer': () async =>
account.isPopulated() ? account.getUsername() : null,
'Demo-Modus': () async => account.isDemo,
'Login Flow v2': () async => account.usesLoginFlow,
'App-Passwort (Dateien)': () async => account.hasAppPassword(),
'App-Passwort (Talk)': () async => account.hasAppPasswordTalk(),
'Session-Epoche': () async => account.sessionEpoch,
'MC-Token-ID': tokens.readTokenId,
'MC-Token läuft ab': tokens.readExpiresAt,
}),
),
(
title: 'Push',
rows: await rows({
'Benachrichtigungen': () async =>
settings.notificationSettings.enabled,
'FCM-Token': FirebaseMessaging.instance.getToken,
for (final type in PushRegistrationType.values) ...{
'Registriert (${type.name})': () => push.isRegistered(type),
'Letzte Registrierung (${type.name})': () =>
push.lastRegistrationAt(type),
'Letzter Fehler (${type.name})': () =>
push.lastRegistrationError(type),
},
}),
),
(
title: 'Sonstiges',
rows: await rows({
'Telemetrie-Geräte-ID': TelemetryDeviceId.resolve,
'Widget zuletzt abgerufen': WidgetSync.getFetchedAt,
}),
),
];
}
String _asText(List<_Section> sections) => [
for (final s in sections) ...[
'## ${s.title}',
for (final r in s.rows) '${r.label}: ${r.value}',
'',
],
].join('\n');
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Diagnose'),
actions: [
IconButton(
tooltip: 'Neu laden',
icon: const Icon(Icons.refresh),
onPressed: () => setState(() {
_sections = _collect();
}),
),
FutureBuilder(
future: _sections,
builder: (context, snapshot) => IconButton(
tooltip: 'Alles kopieren',
icon: const Icon(Icons.copy_all_outlined),
onPressed: snapshot.hasData
? () => copyToClipboard(context, _asText(snapshot.data!))
: null,
),
),
],
),
body: FutureBuilder(
future: _sections,
builder: (context, snapshot) {
final sections = snapshot.data;
if (sections == null) {
return const Center(child: AppProgressIndicator.large());
}
return ListView(
children: [
for (final section in sections) ...[
ListTile(
dense: true,
title: Text(
section.title,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
for (final row in section.rows)
ListTile(
dense: true,
title: Text(row.label),
subtitle: SelectableText(row.value),
onLongPress: () => copyToClipboard(context, row.value),
),
if (section.title == 'Push')
ListTile(
leading: const CenteredLeading(
Icon(Icons.notifications_active_outlined),
),
title: const Text('Push-Status prüfen'),
trailing: const Icon(Icons.arrow_right),
onTap: () => showPushStatusSheet(context),
),
if (section.title == 'Sonstiges')
AsyncListTile(
leading: const CenteredLeading(Icon(Icons.widgets_outlined)),
title: const Text('Widget jetzt aktualisieren'),
closeOnSuccess: false,
onPressed: WidgetBackgroundTask.requestImmediateRefresh,
),
const Divider(),
],
],
);
},
),
);
}
@@ -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/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;
@@ -24,15 +35,19 @@ class DevToolsSection extends StatefulWidget {
}
class _DevToolsSectionState extends State<DevToolsSection> {
// Kept across rebuilds: the size walk lists the whole cache directory.
Future<int>? _cacheSize;
@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(
@@ -42,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,
),
],
);
@@ -102,99 +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: _cacheSize ??= CacheStore.instance.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: () => CacheStore.instance.clear().then(
(value) => setState(() => _cacheSize = null),
),
).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
@@ -206,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)],
);
}
@@ -237,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]/...)'
@@ -257,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: (_) {