206 lines
6.0 KiB
Dart
206 lines
6.0 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../utils/clipboard_helper.dart';
|
|
|
|
/// 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(Object? data) => _encoder.convert(toEncodable(data));
|
|
|
|
/// 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),
|
|
),
|
|
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,
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|