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