reworked developer tools with diagnostics, storage viewers and release-capable debug overlays
This commit is contained in:
@@ -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: (_) {
|
||||
|
||||
Reference in New Issue
Block a user