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
+4
View File
@@ -5,6 +5,8 @@ import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
import '../utils/directory_size.dart';
/// Persistent disk cache for the in-app [UserAvatar] widget.
///
/// The widget keeps a session-scoped in-memory LRU; this store survives app
@@ -156,6 +158,8 @@ class AvatarDiskCache {
}
}
Future<int> totalSize() async => directorySize(await _directory());
/// Wipes the whole cache — used by the argument-less `invalidateAvatarCache`
/// (e.g. on logout).
Future<void> clear() async {
+217
View File
@@ -0,0 +1,217 @@
import 'dart:convert';
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 '../../extensions/date_time.dart';
import '../../routing/app_routes.dart';
import '../../state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../state/app/modules/breaker/bloc/breaker_bloc.dart';
import '../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../storage/hydrated_storage_bootstrap.dart';
import '../confirm_dialog.dart';
import '../placeholder_view.dart';
import 'json_viewer.dart';
/// Lists every key persisted through [HydratedBloc.storage].
class BlocStorageView extends StatefulWidget {
const BlocStorageView({super.key});
/// All persisted keys; `null` when the storage can't be enumerated.
static List<String>? keys() {
final storage = HydratedBloc.storage;
if (storage is InMemoryStorage) return storage.keys.toList()..sort();
try {
// hydrated_bloc exposes no key listing; the Hive instance is the only
// way in.
// ignore: invalid_use_of_visible_for_testing_member
final box = HydratedStorage.hive.box<dynamic>('hydrated_box');
return box.keys.map((k) => '$k').toList()..sort();
} on Object {
return null;
}
}
@override
State<BlocStorageView> createState() => _BlocStorageViewState();
}
class _Entry {
final String key;
final Object? value;
final int size;
final DateTime? lastFetch;
_Entry(this.key, this.value)
: size = utf8.encode(jsonEncode(JsonViewer.toEncodable(value))).length,
lastFetch = _lastFetch(value);
static DateTime? _lastFetch(Object? value) {
if (value is! Map) return null;
final meta = value['meta'];
if (meta is! Map || meta['timestamp'] is! int) return null;
final ts = meta['timestamp'] as int;
return ts > 0 ? DateTime.fromMillisecondsSinceEpoch(ts) : null;
}
}
class _BlocStorageViewState extends State<BlocStorageView> {
List<_Entry>? _entries;
@override
void initState() {
super.initState();
_load();
}
void _load() {
final keys = BlocStorageView.keys();
setState(
() => _entries = keys
?.map((k) => _Entry(k, HydratedBloc.storage.read(k)))
.toList(),
);
}
/// Blocs that live for the whole app session would simply re-persist their
/// in-memory state, so those are reset through the bloc itself.
Future<void> Function()? _liveReset(String key) {
Future<void> Function() loadable(
LoadableHydratedBloc<dynamic, dynamic, dynamic> bloc,
) => () async {
await bloc.reset();
bloc.retry();
};
return switch (key) {
'TimetableBloc' => loadable(context.read<TimetableBloc>()),
'ChatListBloc' => loadable(context.read<ChatListBloc>()),
'ChatBloc' => loadable(context.read<ChatBloc>()),
'BreakerBloc' => loadable(context.read<BreakerBloc>()),
'CapabilitiesCubit' => () async {
final cubit = context.read<CapabilitiesCubit>();
await cubit.reset();
await cubit.load();
},
'NextcloudCapabilitiesCubit' => () async {
final cubit = context.read<NextcloudCapabilitiesCubit>();
await cubit.reset();
await cubit.load();
},
'SettingsCubit' => context.read<SettingsCubit>().reset,
_ => null,
};
}
Future<void> _delete(String key) async {
final reset = _liveReset(key);
if (reset != null) {
await reset();
} else {
await HydratedBloc.storage.delete(key);
}
}
void _confirmDelete(_Entry entry) {
final isSettings = entry.key == 'SettingsCubit';
ConfirmDialog(
title: isSettings ? 'Einstellungen zurücksetzen' : 'Eintrag löschen',
content: isSettings
? 'Alle Einstellungen werden auf die Standardwerte zurückgesetzt.'
: '„${entry.key}“ wird gelöscht und bei Bedarf neu geladen.',
confirmButton: 'Löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
await _delete(entry.key);
if (mounted) _load();
},
).asDialog(context);
}
void _confirmDeleteAll() {
ConfirmDialog(
title: 'BLoC-Speicher leeren',
content:
'Alle gespeicherten Modul-Zustände außer den Einstellungen werden '
'gelöscht und bei Bedarf neu geladen.',
confirmButton: 'Alles löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
for (final entry in [...?_entries]) {
if (entry.key == 'SettingsCubit') continue;
await _delete(entry.key);
}
if (mounted) _load();
},
).asDialog(context);
}
@override
Widget build(BuildContext context) {
final entries = _entries;
return Scaffold(
appBar: AppBar(
title: const Text('BLoC-Speicher'),
actions: [
IconButton(
tooltip: 'Neu laden',
icon: const Icon(Icons.refresh),
onPressed: _load,
),
if (entries != null && entries.isNotEmpty)
IconButton(
tooltip: 'Alles löschen',
icon: const Icon(Icons.delete_sweep_outlined),
onPressed: _confirmDeleteAll,
),
],
),
body: entries == null
? const PlaceholderView(
icon: Icons.error_outline,
text: 'Speicher lässt sich nicht auflisten',
)
: entries.isEmpty
? const PlaceholderView(
icon: Icons.hourglass_empty,
text: 'Keine Daten',
)
: ListView.builder(
itemCount: entries.length,
itemBuilder: (context, index) {
final entry = entries[index];
final live = _liveReset(entry.key) != null;
return ListTile(
leading: Icon(
live ? Icons.bolt_outlined : Icons.inventory_2_outlined,
),
title: Text(entry.key),
subtitle: Text(
[
filesize(entry.size),
if (entry.lastFetch != null)
'geladen ${entry.lastFetch!.formatRelative()}',
].join(' · '),
),
onTap: () => AppRoutes.openJsonViewer(
context,
entry.value,
title: entry.key,
),
trailing: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDelete(entry),
),
);
},
),
);
}
}
+90 -36
View File
@@ -1,62 +1,116 @@
import 'dart:convert';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../../../widget/placeholder_view.dart';
import '../../api/cache_store.dart';
import '../../extensions/date_time.dart';
import '../../routing/app_routes.dart';
import '../app_progress_indicator.dart';
import 'json_viewer.dart';
import '../confirm_dialog.dart';
import '../placeholder_view.dart';
/// Lists the [CacheStore] entries (one file per request key), newest first.
class CacheView extends StatefulWidget {
const CacheView({super.key});
@override
State<CacheView> createState() => _CacheViewState();
}
class _CacheViewState extends State<CacheView> {
late final Future<Map<String, CacheEntry>> files = CacheStore.instance
.readAll();
late Future<List<MapEntry<String, CacheEntry>>> _entries = _read();
Future<List<MapEntry<String, CacheEntry>>> _read() async =>
(await CacheStore.instance.readAll()).entries.toList()
..sort((a, b) => b.value.lastUpdate.compareTo(a.value.lastUpdate));
void _reload() => setState(() {
_entries = _read();
});
void _confirmDelete(String key) {
ConfirmDialog(
title: 'Eintrag löschen',
content: '„$key“ wird gelöscht und bei Bedarf neu geladen.',
confirmButton: 'Löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
await CacheStore.instance.delete(key);
if (mounted) _reload();
},
).asDialog(context);
}
void _confirmClear() {
ConfirmDialog(
title: 'Request-Cache leeren',
content:
'Alle Einträge werden gelöscht. Der Cache baut sich bei Nutzung der '
'App automatisch wieder auf.',
confirmButton: 'Alles löschen',
errorBuilder: (e) => '$e',
onConfirmAsync: () async {
await CacheStore.instance.clear();
if (mounted) _reload();
},
).asDialog(context);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Cache storage')),
appBar: AppBar(
title: const Text('Request-Cache'),
actions: [
IconButton(
tooltip: 'Neu laden',
icon: const Icon(Icons.refresh),
onPressed: _reload,
),
IconButton(
tooltip: 'Alles löschen',
icon: const Icon(Icons.delete_sweep_outlined),
onPressed: _confirmClear,
),
],
),
body: FutureBuilder(
future: files,
future: _entries,
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
return ListView.builder(
itemCount: snapshot.data!.length,
itemBuilder: (context, index) {
final key = snapshot.data!.keys.elementAt(index);
final element = snapshot.data![key]!;
return ListTile(
leading: const Icon(Icons.text_snippet_outlined),
title: Text(key),
subtitle: Text(
'${filesize(utf8.encode(element.json).length)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element.lastUpdate).fromNow()}',
),
trailing: const Icon(Icons.arrow_right),
onTap: () => JsonViewer.asDialog(
context,
jsonDecode(element.json) as Map<String, dynamic>,
),
);
},
);
} else if (snapshot.connectionState != ConnectionState.done) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: AppProgressIndicator.large());
} else {
return const Center(
child: PlaceholderView(
icon: Icons.hourglass_empty,
text: 'Keine Daten',
),
}
final entries = snapshot.data ?? const [];
if (entries.isEmpty) {
return const PlaceholderView(
icon: Icons.hourglass_empty,
text: 'Keine Daten',
);
}
return ListView.builder(
itemCount: entries.length,
itemBuilder: (context, index) {
final MapEntry(:key, :value) = entries[index];
return ListTile(
leading: const Icon(Icons.text_snippet_outlined),
title: Text(key),
subtitle: Text(
'${filesize(utf8.encode(value.json).length)} · '
'${DateTime.fromMillisecondsSinceEpoch(value.lastUpdate).formatRelative()}',
),
onTap: () => AppRoutes.openJsonViewer(
context,
jsonDecode(value.json),
title: key,
),
trailing: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete_outline),
onPressed: () => _confirmDelete(key),
),
);
},
);
},
),
);
+205
View File
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// Release-capable frame and memory statistics drawn above [child]. Frame data
/// comes from [SchedulerBinding.addTimingsCallback], which the engine reports
/// in batches (about once per second in release builds).
class DebugStatsOverlay extends StatefulWidget {
final bool showFrameStats;
final bool showMemoryStats;
final Widget child;
const DebugStatsOverlay({
required this.showFrameStats,
required this.showMemoryStats,
required this.child,
super.key,
});
@override
State<DebugStatsOverlay> createState() => _DebugStatsOverlayState();
}
class _DebugStatsOverlayState extends State<DebugStatsOverlay> {
static const _sampleWindow = 240;
final List<FrameTiming> _frames = [];
int _jankTotal = 0;
int _frameTotal = 0;
Timer? _ticker;
bool _listening = false;
bool get _active => widget.showFrameStats || widget.showMemoryStats;
@override
void initState() {
super.initState();
_sync();
}
@override
void didUpdateWidget(DebugStatsOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
_sync();
}
void _sync() {
if (widget.showFrameStats && !_listening) {
SchedulerBinding.instance.addTimingsCallback(_onTimings);
_listening = true;
} else if (!widget.showFrameStats && _listening) {
SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_listening = false;
_frames.clear();
_jankTotal = 0;
_frameTotal = 0;
}
if (_active) {
_ticker ??= Timer.periodic(
const Duration(seconds: 1),
(_) => setState(() {}),
);
} else {
_ticker?.cancel();
_ticker = null;
}
}
Duration get _budget {
final rate = View.maybeOf(context)?.display.refreshRate ?? 60;
return Duration(microseconds: (1e6 / (rate > 0 ? rate : 60)).round());
}
void _onTimings(List<FrameTiming> timings) {
final budget = _budget;
for (final t in timings) {
_frameTotal++;
if (t.buildDuration > budget || t.rasterDuration > budget) _jankTotal++;
}
_frames.addAll(timings);
if (_frames.length > _sampleWindow) {
_frames.removeRange(0, _frames.length - _sampleWindow);
}
}
@override
void dispose() {
if (_listening) SchedulerBinding.instance.removeTimingsCallback(_onTimings);
_ticker?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_active) return widget.child;
return Stack(
textDirection: TextDirection.ltr,
children: [
widget.child,
Positioned(
left: 4,
bottom: MediaQuery.paddingOf(context).bottom + 4,
child: IgnorePointer(
child: MediaQuery.withNoTextScaling(
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
child: DefaultTextStyle(
style: const TextStyle(
color: Colors.white,
fontSize: 10,
height: 1.3,
fontFamily: 'monospace',
decoration: TextDecoration.none,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.showFrameStats) ..._frameLines(),
if (widget.showMemoryStats) ..._memoryLines(),
],
),
),
),
),
),
),
),
],
);
}
List<Widget> _frameLines() {
final budgetMs = _budget.inMicroseconds / 1000;
if (_frames.isEmpty) {
return [Text('Frames: – (Budget ${budgetMs.toStringAsFixed(1)} ms)')];
}
final build = _sortedMs((t) => t.buildDuration);
final raster = _sortedMs((t) => t.rasterDuration);
final jankPct = _frameTotal == 0 ? 0 : _jankTotal * 100 / _frameTotal;
final lastWallclock = _frames.last.timestampInMicroseconds(
FramePhase.rasterFinish,
);
final recent = _frames
.where(
(t) =>
lastWallclock -
t.timestampInMicroseconds(FramePhase.rasterFinish) <
1000000,
)
.length;
return [
Text(
'Budget ${budgetMs.toStringAsFixed(1)} ms · letzte Sek. $recent Frames',
),
Text(
'Build p50 ${_p(build, 50)} p90 ${_p(build, 90)} max ${_ms(build.last)}',
),
Text(
'Raster p50 ${_p(raster, 50)} p90 ${_p(raster, 90)} max ${_ms(raster.last)}',
),
Text('Jank $_jankTotal/$_frameTotal (${jankPct.toStringAsFixed(1)} %)'),
];
}
List<Widget> _memoryLines() {
final cache = PaintingBinding.instance.imageCache;
return [
Text(
'RSS ${filesize(ProcessInfo.currentRss)} · max ${filesize(ProcessInfo.maxRss)}',
),
Text(
'Bilder ${cache.currentSize}/${cache.maximumSize} · '
'${filesize(cache.currentSizeBytes)}/${filesize(cache.maximumSizeBytes)} · '
'live ${cache.liveImageCount}',
),
];
}
List<double> _sortedMs(Duration Function(FrameTiming) pick) =>
_frames.map((t) => pick(t).inMicroseconds / 1000).toList()..sort();
String _p(List<double> sorted, int percentile) {
final i = min(
sorted.length - 1,
(sorted.length * percentile / 100).floor(),
);
return _ms(sorted[i]);
}
String _ms(double v) => v.toStringAsFixed(1).padLeft(5);
}
+4 -4
View File
@@ -2,9 +2,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../routing/app_routes.dart';
import '../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../centered_leading.dart';
import 'json_viewer.dart';
class DebugTile {
BuildContext context;
@@ -16,9 +16,9 @@ class DebugTile {
(onlyInDebug ? kDebugMode : true);
Widget jsonData(Map<String, dynamic> data) => callback(
title: 'JSON daten anzeigen',
onTab: () => JsonViewer.asDialog(context, data),
);
title: 'JSON daten anzeigen',
onTab: () => AppRoutes.openJsonViewer(context, data),
);
Widget callback({
String title = 'Debugaktion',
+189 -31
View File
@@ -4,43 +4,201 @@ import 'package:flutter/material.dart';
import '../../utils/clipboard_helper.dart';
class JsonViewer {
/// Collapsible tree view for decoded JSON (maps, lists, scalars). Rows are
/// flattened lazily so multi-megabyte cache payloads stay scrollable.
class JsonViewer extends StatefulWidget {
final String title;
final Object? data;
const JsonViewer({required this.data, this.title = 'Rohdaten', super.key});
static final _encoder = const JsonEncoder.withIndent(' ');
static String format(Map<String, dynamic> jsonInput) =>
_encoder.convert(jsonInput);
static String format(Object? data) => _encoder.convert(toEncodable(data));
static void asDialog(BuildContext context, Map<String, dynamic> dataMap) {
showDialog(
context: context,
builder: (dialogCtx) => AlertDialog(
scrollable: true,
title: const Row(
children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')],
/// Makes arbitrary decoded storage values (Hive maps with dynamic keys,
/// DateTimes, …) JSON-encodable.
static Object? toEncodable(Object? value) => switch (value) {
null || bool() || num() || String() => value,
Map() => {for (final e in value.entries) '${e.key}': toEncodable(e.value)},
Iterable() => [for (final e in value) toEncodable(e)],
DateTime() => value.toIso8601String(),
_ => value.toString(),
};
@override
State<JsonViewer> createState() => _JsonViewerState();
}
class _Row {
final String path;
final String? label;
final Object? value;
final int depth;
const _Row(this.path, this.label, this.value, this.depth);
bool get isContainer => value is Map || value is List;
}
class _JsonViewerState extends State<JsonViewer> {
late final Object? _data = JsonViewer.toEncodable(widget.data);
final Set<String> _expanded = {''};
List<_Row> _rows = const [];
@override
void initState() {
super.initState();
_rebuildRows();
}
void _rebuildRows() {
final rows = <_Row>[];
void walk(String path, String? label, Object? value, int depth) {
rows.add(_Row(path, label, value, depth));
if (!_expanded.contains(path)) return;
if (value is Map) {
for (final e in value.entries) {
walk('$path/${e.key}', '${e.key}', e.value, depth + 1);
}
} else if (value is List) {
for (var i = 0; i < value.length; i++) {
walk('$path/$i', '[$i]', value[i], depth + 1);
}
}
}
walk('', null, _data, 0);
_rows = rows;
}
void _toggle(String path) => setState(() {
if (!_expanded.remove(path)) _expanded.add(path);
_rebuildRows();
});
void _setAll({required bool expanded}) => setState(() {
_expanded
..clear()
..add('');
if (expanded) {
void collect(String path, Object? value) {
if (value is Map) {
_expanded.add(path);
for (final e in value.entries) {
collect('$path/${e.key}', e.value);
}
} else if (value is List) {
_expanded.add(path);
for (var i = 0; i < value.length; i++) {
collect('$path/$i', value[i]);
}
}
}
collect('', _data);
}
_rebuildRows();
});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
IconButton(
tooltip: 'Alles aufklappen',
icon: const Icon(Icons.unfold_more),
onPressed: () => _setAll(expanded: true),
),
content: Text(JsonViewer.format(dataMap)),
actions: [
TextButton(
onPressed: () => copyToClipboard(
dialogCtx,
JsonViewer.format(dataMap),
successMessage: 'Formatiertes JSON kopiert',
IconButton(
tooltip: 'Alles zuklappen',
icon: const Icon(Icons.unfold_less),
onPressed: () => _setAll(expanded: false),
),
PopupMenuButton<bool>(
onSelected: (pretty) => copyToClipboard(
context,
pretty ? JsonViewer.format(_data) : jsonEncode(_data),
successMessage: pretty
? 'Formatiertes JSON kopiert'
: 'Inline JSON kopiert',
),
itemBuilder: (_) => const [
PopupMenuItem(value: true, child: Text('JSON kopieren')),
PopupMenuItem(value: false, child: Text('Inline kopieren')),
],
),
],
),
body: ListView.builder(
itemCount: _rows.length,
itemBuilder: (context, index) => _buildRow(context, _rows[index]),
),
);
Widget _buildRow(BuildContext context, _Row row) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final mono = theme.textTheme.bodyMedium!.copyWith(fontFamily: 'monospace');
final value = row.value;
final expanded = _expanded.contains(row.path);
final (String text, Color color) = switch (value) {
Map() => (expanded ? '{' : '{ ${value.length} }', colors.outline),
List() => (expanded ? '[' : '[ ${value.length} ]', colors.outline),
String() => ('"$value"', colors.tertiary),
num() || bool() => ('$value', colors.primary),
_ => ('null', colors.outline),
};
return InkWell(
onTap: row.isContainer ? () => _toggle(row.path) : null,
onLongPress: () => copyToClipboard(
context,
value is String ? value : JsonViewer.format(value),
),
child: Padding(
padding: EdgeInsets.only(
left: 8.0 + row.depth * 16,
right: 8,
top: 3,
bottom: 3,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 20,
child: row.isContainer
? Icon(
expanded ? Icons.arrow_drop_down : Icons.arrow_right,
size: 20,
)
: null,
),
child: const Text('Kopieren'),
),
TextButton(
onPressed: () => copyToClipboard(
dialogCtx,
dataMap.toString(),
successMessage: 'Inline JSON kopiert',
Expanded(
child: Text.rich(
TextSpan(
style: mono,
children: [
if (row.label != null)
TextSpan(
text: '${row.label}: ',
style: TextStyle(color: colors.secondary),
),
TextSpan(
text: text,
style: TextStyle(color: color),
),
],
),
maxLines: row.isContainer ? 1 : 8,
overflow: TextOverflow.ellipsis,
),
),
child: const Text('Inline Kopieren'),
),
TextButton(
onPressed: () => Navigator.of(dialogCtx).pop(),
child: const Text('Schließen'),
),
],
],
),
),
);
}