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
+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'),
),
],
],
),
),
);
}