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 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 { late final Object? _data = JsonViewer.toEncodable(widget.data); final Set _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( 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, ), ), ], ), ), ); } }