library json_editor_flutter; import 'dart:convert'; import 'dart:async'; import 'dart:math'; import 'dart:ui'; import 'package:dartx/dartx.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; const _space = 18.0; const _textStyle = TextStyle(fontSize: 16); const _options = Icon(Icons.more_horiz, size: 16); const _expandIconWidth = 10.0; const _rowHeight = 30.0; const _popupMenuHeight = 30.0; const _popupMenuItemPadding = 20.0; const _textSpacer = SizedBox(width: 5); const _newKey = "new_key_added"; const _downArrow = SizedBox( width: _expandIconWidth, child: Icon(CupertinoIcons.arrowtriangle_down_fill, size: 14), ); const _rightArrow = SizedBox( width: _expandIconWidth, child: Icon(CupertinoIcons.arrowtriangle_right_fill, size: 14), ); const _newDataValue = { "string": "", "bool": false, "num": 0, }; bool _enableMoreOptions = true; bool _enableKeyEdit = true; bool _enableValueEdit = true; // enum _OptionItems { map, list, string, bool, num, delete, protocols, configElement } typedef _OptionItems = String; enum _SearchActions { next, prev } /// Supported editors for JSON Editor. enum Editors { tree, text } const Map> protocolSchemaValues = { "xray": { "type": "xray", "tag": "xray-out", "xray_outbound_raw": {}, "xray_fragment": {"packets": "tlshello", "interval": "1-10", "length": "1-10"} }, "warp": {"type": "custom", "key": "", "host": "", "port": 808, "fake_packets": "1-10", "fake_packets_size": "1-10", "fake_packets_delay": "1-10", "fake_packets_mode": "m4"}, "vless": { "type": "vless", "tag": "vless-out", "server": "127.0.0.1", "server_port": 1080, "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", "flow": "xtls-rprx-vision", "packet_encoding": "", }, "vmess": { "type": "vmess", "tag": "vmess-out", "server": "127.0.0.1", "server_port": 1080, "uuid": "bf000d23-0752-40b4-affe-68f7707a9661", "security": "auto", "global_padding": false, "authenticated_length": true, "packet_encoding": "", }, "trojan": { "type": "trojan", "tag": "trojan-out", "server": "127.0.0.1", "server_port": 1080, "password": "8JCsPssfgS8tiRwiMlhARg==", }, "hysteria": { "type": "hysteria", "tag": "hysteria-out", "server": "127.0.0.1", "server_port": 1080, "up": "100 Mbps", "up_mbps": 100, "down": "100 Mbps", "down_mbps": 100, "obfs": "daylight", "auth": "", "auth_str": "password", "recv_window_conn": 0, "recv_window": 0, "disable_mtu_discovery": false, "tls": {"enabled": true}, }, "hysteria2": { "type": "hysteria2", "tag": "hy2-out", "server": "127.0.0.1", "server_port": 1080, "up_mbps": 100, "down_mbps": 100, "obfs": {"type": "salamander", "password": "cry_me_a_r1ver"}, "password": "goofy_ahh_password", "tls": { "enabled": true, } }, "shadowsocks": { "type": "shadowsocks", "tag": "ss-out", "server": "127.0.0.1", "server_port": 1080, "method": "2022-blake3-aes-128-gcm", "password": "8JCsPssfgS8tiRwiMlhARg==", "plugin": "", "plugin_opts": "", "udp_over_tcp": false, }, "socks": { "type": "socks", "tag": "socks-out", "server": "127.0.0.1", "server_port": 1080, "version": "5", "username": "", "password": "", "udp_over_tcp": false, }, "http": { "type": "http", "tag": "http-out", "server": "127.0.0.1", "server_port": 1080, "username": "", "password": "", "path": "", "headers": {}, "tls": {"enabled": false}, }, "wireguard": { "type": "wireguard", "tag": "wireguard-out", "server": "127.0.0.1", "server_port": 1080, "system_interface": false, "gso": false, "interface_name": "wg0", "local_address": ["10.0.0.2/32"], "private_key": "YNXtAzepDqRv9H52osJVDQnznT5AM11eCK3ESpwSt04=", "peer_public_key": "Z1XXLsKYkYxuiYjJIkRvtIKFepCYHTgON+GwPq7SOV4=", "pre_shared_key": "31aIhAPwktDGpH4JDhA8GNvjFXEf/a6+UaQRyOAiyfM=", "reserved": [0, 0, 0], "workers": 4, "mtu": 1408, "fake_packets": "1-10", "fake_packets_size": "1-10", "fake_packets_delay": "1-10", "fake_packets_mode": "m4" }, "tuic": { "type": "tuic", "tag": "tuic-out", "server": "127.0.0.1", "server_port": 1080, "uuid": "2DD61D93-75D8-4DA4-AC0E-6AECE7EAC365", "password": "hello", "congestion_control": "cubic", "udp_relay_mode": "native", "udp_over_stream": false, "zero_rtt_handshake": false, "heartbeat": "10s", "tls": {"enabled": true}, }, "ssh": { "type": "ssh", "tag": "ssh-out", "server": "127.0.0.1", "server_port": 22, "user": "root", "password": "admin", "private_key": "", "private_key_passphrase": "", "host_key": [""], "client_version": "SSH-2.0-OpenSSH_7.4p1", }, }; const Map>> exampleSchemaValues = { "config.outbounds.transport": { "browser user-agent": { "header": {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0"} } }, "config.outbounds.tls": { "fragment": { "tls_fragment": {"enabled": true, "size": "1-10", "sleep": "1-10"} }, "utls": { "utls": {"enabled": true, "fingerprint": "chrome"} } }, "config.outbounds": { "multiplex": { "multiplex": { "enabled": true, "protocol": "smux", "max_connections": 4, "min_streams": 4, "max_streams": 0, "padding": false, "brutal": {"enabled": true, "up_mbps": 100, "down_mbps": 100} }, }, "reality": { "tls": { "enabled": true, "server_name": "", "min_version": "", "max_version": "", "utls": {"enabled": true, "fingerprint": "chrome"}, "reality": {"enabled": true, "public_key": "", "short_id": ""} }, }, "tls": { "tls": { "enabled": true, "utls": {"enabled": true, "fingerprint": "chrome"}, "disable_sni": false, "insecure": false, "server_name": "", "alpn": [], "min_version": "", "max_version": "", "tls_fragment": {"enabled": false, "size": "1-10", "sleep": "1-10"} }, }, "websocket": { "transport": {"type": "ws", "path": "", "headers": {}, "max_early_data": 0, "early_data_header_name": ""} }, "grpc": { "transport": {"type": "grpc", "service_name": "TunService", "idle_timeout": "15s", "ping_timeout": "15s", "permit_without_stream": false} }, "quic": { "transport": {"type": "quic"} }, "http": { "transport": {"type": "http", "host": [], "path": "", "method": "", "headers": {}, "idle_timeout": "15s", "ping_timeout": "15s"} }, "httpupgrade": { "transport": {"type": "httpupgrade", "host": "", "path": "", "headers": {}} }, } }; const Map> possibleValues = { "config.outbounds.flow": ["", "xtls-rprx-vision"], "config.outbounds.security": ["", "auto", "none", "zero", "aes-128-gcm", "chacha20-poly1305"], "config.outbounds.method": ["", "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305", "none", "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305"], "config.outbounds.plugin": ["", "obfs-local", "v2ray-plugin"], "config.outbounds.network": ["", "udp", "tcp"], "config.outbounds.multiplex.protocol": ["", "smux", "yamux", "h2mux"], "config.outbounds.tls.min_version": ["", "1.0", "1.1", "1.2", "1.3"], "config.outbounds.tls.max_version": ["", "1.0", "1.1", "1.2", "1.3"], "config.outbounds.tls.utls.fingerprint": ["", "chrome", "chrome_psk", "chrome_psk_shuffle", "chrome_padding_psk_shuffle", "chrome_pq", "chrome_pq_psk", "firefox", "edge", "safari", "360", "qq", "ios", "android", "random", "randomized"], "config.outbounds.packet_encoding": ["", "(none)", "xudp", "packetaddr"], "config.outbounds.transport.type": ["", "http", "ws", "grpc", "quic", "httpupgrade"], "config.outbounds.type": [ "vless", "vmess", "trojan", "xray", "shadowsocks", "wireguard", "hysteria", "hysteria2", "tuic", "ssh", "shadowtls", "custom", "direct", "block", "socks", "http", ] }; /// Edit your JSON object with this Widget. Create, edit and format objects /// using this user friendly widget. class JsonEditor extends StatefulWidget { /// JSON can be edited in two ways, Tree editor or text editor. You can disable /// either of them. /// /// When UI editor is active, you can disable adding/deleting keys by using /// [enableMoreOptions]. Editing keys and values can also be disabled by using /// [enableKeyEdit] and [enableValueEdit]. /// /// When text editor is active, it will simply ignore [enableMoreOptions], /// [enableKeyEdit] and [enableValueEdit]. /// /// [duration] is the debounce time for [onChanged] function. Defaults to /// 500 milliseconds. /// /// [editors] is the supported list of editors. First element will be /// used as default editor. Defaults to `[Editors.tree, Editors.text]`. const JsonEditor({ super.key, required this.json, required this.onChanged, this.duration = const Duration(milliseconds: 500), this.enableMoreOptions = true, this.enableKeyEdit = true, this.enableValueEdit = true, this.editors = const [Editors.tree, Editors.text], this.themeColor, this.actions = const [], this.enableHorizontalScroll = false, this.searchDuration = const Duration(milliseconds: 500), this.hideEditorsMenuButton = false, this.expandedObjects = const [], }) : assert(editors.length > 0, "editors list cannot be empty"); /// JSON string to be edited. final String json; /// Callback function that will be called with the new [dynamic] data. final ValueChanged onChanged; /// Debounce duration for [onChanged] function. final Duration duration; /// Enables more options like adding or deleting data. Defaults to `true`. final bool enableMoreOptions; /// Enables editing of keys. Defaults to `true`. final bool enableKeyEdit; /// Enables editing of values. Defaults to `true`. final bool enableValueEdit; /// Theme color for the editor. Changes the border color and header color. final Color? themeColor; /// List of supported editors. First element will be used as default editor. final List editors; /// A list of Widgets to display in a row at the end of header. final List actions; /// Enables horizontal scroll for the tree view. Defaults to `false`. final bool enableHorizontalScroll; /// Debounce duration for search function. final Duration searchDuration; /// Hides the option of changing editor. Defaults to `false`. final bool hideEditorsMenuButton; /// [expandedObjects] refers to the objects that will be expanded by /// default. Index can be provided when the data is a List. /// /// Examples: /// ```dart /// data = { /// "hobbies": ["Reading books", "Playing Cricket"], /// "education": [ /// {"name": "Bachelor of Engineering", "marks": 75}, /// {"name": "Master of Engineering", "marks": 72}, /// ], /// } /// ``` /// /// For the given data /// 1. To expand education pass => `["education"]` /// 2. To expand hobbies and education pass => `["hobbies", "education"]` /// 3. To expand the first element (index 0) of education list, this means /// we need to expand education too. In this case you need not to pass /// "education" separately. Just pass a list of all nested objects => /// `[["education", 0]]` /// /// ```dart /// JsonEditor( /// expandedObjects: const [ /// "hobbies", /// ["education", 0] // expands nested object in education /// ], /// onChanged: (_) {}, /// json: jsonEncode(data), /// ) /// ``` final List expandedObjects; @override State createState() => _JsonEditorState(); } class _JsonEditorState extends State { Timer? _timer; Timer? _searchTimer; late dynamic _data; late final _themeColor = widget.themeColor ?? Theme.of(context).primaryColor; late Editors _editor = widget.editors.first; bool _onError = false; bool? allExpanded; late final _controller = TextEditingController()..text = _stringifyData(_data, 0, true); late final _scrollController = ScrollController(); final _matchedKeys = {}; final _matchedKeysLocation = []; int? _focusedKey; int? _results; late final _expandedObjects = { ["config"].toString(): true, if (widget.expandedObjects.isNotEmpty) ...getExpandedParents(), }; Map getExpandedParents() { final map = {}; for (var key in widget.expandedObjects) { if (key is List) { final newExpandList = ["config", ...key]; for (int i = newExpandList.length - 1; i > 0; i--) { map[newExpandList.toString()] = true; newExpandList.removeLast(); } } else { map[["config", key].toString()] = true; } } return map; } void callOnChanged() { if (_timer?.isActive ?? false) _timer?.cancel(); _timer = Timer(widget.duration, () { widget.onChanged(jsonDecode(jsonEncode(_data))); }); } void parseData(String value) { if (_timer?.isActive ?? false) _timer?.cancel(); _timer = Timer(widget.duration, () { try { _data = jsonDecode(value); widget.onChanged(_data); setState(() { _onError = false; }); } catch (_) { setState(() { _onError = true; }); } }); } void copyData() async { await Clipboard.setData( ClipboardData(text: JsonEncoder.withIndent(' ').convert(_data)), ); } bool updateParentObjects(List newExpandList) { bool needsRebuilding = false; for (int i = newExpandList.length - 1; i >= 0; i--) { if (_expandedObjects[newExpandList.toString()] == null) { _expandedObjects[newExpandList.toString()] = true; needsRebuilding = true; } newExpandList.removeLast(); } return needsRebuilding; } void findMatchingKeys(data, String text, List nestedParents) { if (data is Map) { final keys = data.keys.toList(); for (var key in keys) { final keyName = key.toString(); if (keyName.toLowerCase().contains(text) || (data[key] is String && data[key].toString().toLowerCase().contains(text))) { _results = _results! + 1; _matchedKeys[keyName] = true; _matchedKeysLocation.add([...nestedParents, key]); } if (data[key] is Map) { findMatchingKeys(data[key], text, [...nestedParents, key]); } else if (data[key] is List) { findMatchingKeys(data[key], text, [...nestedParents, key]); } } } else if (data is List) { for (int i = 0; i < data.length; i++) { final item = data[i]; if (item is Map) { findMatchingKeys(item, text, [...nestedParents, i]); } else if (item is List) { findMatchingKeys(item, text, [...nestedParents, i]); } } } } void onSearch(String text) { if (_searchTimer?.isActive ?? false) _searchTimer?.cancel(); _searchTimer = Timer(widget.searchDuration, () async { _matchedKeys.clear(); _matchedKeysLocation.clear(); _focusedKey = null; if (text.isEmpty) { setState(() { _results = null; }); } else { _results = 0; findMatchingKeys(_data, text.toLowerCase(), ["config"]); setState(() {}); if (_matchedKeys.isNotEmpty) { _focusedKey = 0; scrollTo(0); } } }); } int getOffset(List toFind) { int offset = 1; bool keyFound = false; void calculateOffset(data, List parents, List toFind) { if (keyFound) return; if (data is Map) { for (var entry in data.entries) { if (keyFound) return; offset++; final newList = [...parents, entry.key]; if (entry.key == toFind.last && newList.toString() == toFind.toString()) { keyFound = true; return; } if (entry.value is Map || entry.value is List) { if (_expandedObjects[newList.toString()] == true && !keyFound) { calculateOffset(entry.value, newList, toFind); } } } } else if (data is List) { for (int i = 0; i < data.length; i++) { if (keyFound) return; offset++; if (data[i] is Map || data[i] is List) { final newList = [...parents, i]; if (_expandedObjects[newList.toString()] == true && !keyFound) { calculateOffset(data[i], newList, toFind); } } } } } calculateOffset(_data, ["config"], toFind); return offset; } void scrollTo(int index) { final toFind = [..._matchedKeysLocation[index]]; final needsRebuilding = updateParentObjects( [..._matchedKeysLocation[index]]..removeLast(), ); if (needsRebuilding) setState(() {}); Future.delayed(const Duration(milliseconds: 150), () { _scrollController.animateTo( (getOffset(toFind) * _rowHeight) - 90, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, ); }); } void onSearchAction(_SearchActions action) { if (_matchedKeys.isEmpty) return; if (action == _SearchActions.next) { if (_focusedKey != null && _matchedKeysLocation.length - 1 > _focusedKey!) { _focusedKey = _focusedKey! + 1; } else { _focusedKey = 0; } } else { if (_focusedKey != null && _focusedKey! > 0) { _focusedKey = _focusedKey! - 1; } else { _focusedKey = _matchedKeysLocation.length - 1; } } scrollTo(_focusedKey!); } void expandAllObjects(data, List expandedList) { if (data is Map) { for (var entry in data.entries) { if (entry.value is Map || entry.value is List) { final newList = [...expandedList, entry.key]; _expandedObjects[newList.toString()] = true; expandAllObjects(entry.value, newList); } } } else if (data is List) { for (int i = 0; i < data.length; i++) { if (data[i] is Map || data[i] is List) { final newList = [...expandedList, i]; _expandedObjects[newList.toString()] = true; expandAllObjects(data[i], newList); } } } } Widget wrapWithHorizontolScroll(Widget child) { if (widget.enableHorizontalScroll) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: child, ); } return child; } @override void initState() { super.initState(); _data = jsonDecode(widget.json); _enableMoreOptions = widget.enableMoreOptions; _enableKeyEdit = widget.enableKeyEdit; _enableValueEdit = widget.enableValueEdit; } @override void dispose() { _timer?.cancel(); _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( border: Border.all( width: _onError ? 2 : 1, color: _onError ? Colors.red : _themeColor, ), ), child: SizedBox( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ DecoratedBox( decoration: BoxDecoration( color: _themeColor, border: _onError ? const Border( bottom: BorderSide(color: Colors.red, width: 2), ) : null), child: Padding( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 10, ), child: Row( children: [ const Text('Config Editor: '), if (!widget.hideEditorsMenuButton) PopupMenuButton( initialValue: _editor, tooltip: 'Change editor', padding: EdgeInsets.zero, onSelected: (value) { if (value == Editors.text) { _controller.text = _stringifyData(_data, 0, true); } setState(() { _editor = value; }); }, position: PopupMenuPosition.under, enabled: widget.editors.length > 1, constraints: const BoxConstraints( minWidth: 50, maxWidth: 150, ), itemBuilder: (context) { return >[ PopupMenuItem( height: _popupMenuHeight, padding: const EdgeInsets.symmetric(horizontal: 12), enabled: widget.editors.contains(Editors.tree), value: Editors.tree, child: const Text("Tree"), ), PopupMenuItem( height: _popupMenuHeight, padding: const EdgeInsets.symmetric(horizontal: 12), enabled: widget.editors.contains(Editors.text), value: Editors.text, child: const Text("Text"), ), ]; }, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(_editor.name, style: _textStyle), const Icon(Icons.arrow_drop_down, size: 20), ], ), ), const Spacer(), if (_editor == Editors.text) ...[ const SizedBox(width: 20), InkWell( onTap: () { _controller.text = _stringifyData(_data, 0, true); }, child: const Tooltip( message: 'Format', child: Icon(Icons.format_align_left, size: 20), ), ), ] else ...[ const SizedBox(width: 20), if (_results != null) ...[ Text("$_results results"), const SizedBox(width: 5), ], _SearchField(onSearch, onSearchAction), const SizedBox(width: 20), InkWell( onTap: () { _expandedObjects[["config"].toString()] = true; expandAllObjects(_data, ["config"]); setState(() {}); }, child: const Tooltip( message: 'Expand All', child: Icon(Icons.expand, size: 20), ), ), const SizedBox(width: 20), InkWell( onTap: () { _expandedObjects.clear(); setState(() {}); }, child: const Tooltip( message: 'Collapse All', child: Icon(Icons.compress, size: 20), ), ), ], const SizedBox(width: 20), InkWell( onTap: copyData, child: const Tooltip( message: 'Copy', child: Icon(Icons.copy, size: 20), ), ), if (widget.actions.isNotEmpty) const SizedBox(width: 20), ...widget.actions, ], ), ), ), if (_editor == Editors.tree) Expanded( child: SingleChildScrollView( controller: _scrollController, physics: const ClampingScrollPhysics(), child: wrapWithHorizontolScroll( _Holder( key: UniqueKey(), data: _data, keyName: "config", paddingLeft: _space, onChanged: callOnChanged, parentObject: {"config": _data}, setState: setState, matchedKeys: _matchedKeys, allParents: const ["config"], expandedObjects: _expandedObjects, ), ), ), ), if (_editor == Editors.text) Expanded( child: TextFormField( controller: _controller, onChanged: parseData, maxLines: null, minLines: null, expands: true, textAlignVertical: TextAlignVertical.top, decoration: const InputDecoration( border: InputBorder.none, contentPadding: EdgeInsets.only( left: 5, top: 8, bottom: 8, ), ), ), ), ], ), ), ); } } class _Holder extends StatefulWidget { const _Holder({ super.key, this.keyName, required this.data, required this.paddingLeft, required this.onChanged, required this.parentObject, required this.setState, required this.matchedKeys, required this.allParents, required this.expandedObjects, }); final dynamic keyName; final dynamic data; final double paddingLeft; final VoidCallback onChanged; final dynamic parentObject; final StateSetter setState; final Map matchedKeys; final List allParents; final Map expandedObjects; String getKeyPath() { final basePath = allParents.whereType().join('.'); // final typePath = (parentObject['type'] != null && // parentObject['type'] != keyName) // ? '.${parentObject['type']}' // : ''; return '$basePath'; } @override State<_Holder> createState() => _HolderState(); } class _HolderState extends State<_Holder> { late bool isExpanded = widget.expandedObjects[widget.allParents.toString()] == true; void _toggleState() { if (!isExpanded) { widget.expandedObjects[widget.allParents.toString()] = true; } else { widget.expandedObjects.remove(widget.allParents.toString()); } setState(() { isExpanded = !isExpanded; }); } void onSelected(_OptionItems selectedItem) { if (selectedItem == "delete") { if (widget.parentObject is Map) { widget.parentObject.remove(widget.keyName); } else { widget.parentObject.removeAt(widget.keyName); } widget.setState(() {}); } else if (selectedItem == "map") { if (widget.data is Map) { widget.data[_newKey] = Map(); } else { widget.data.add(Map()); } setState(() {}); widget.onChanged(); } else if (exampleSchemaValues.containsKey(selectedItem.split("___")[0])) { final jsonItem = exampleSchemaValues[selectedItem.split("___")[0]]![selectedItem.split("___")[1]]!; for (final key in jsonItem.keys) { widget.data[key] = jsonDecode(jsonEncode(jsonItem[key])); } setState(() {}); } else if (protocolSchemaValues.containsKey(selectedItem)) { final jsonItem = protocolSchemaValues[selectedItem]!; widget.data.add(jsonDecode(jsonEncode(jsonItem))); setState(() {}); } else if (selectedItem == "list") { if (widget.data is Map) { widget.data[_newKey] = []; } else { widget.data.add([]); } setState(() {}); } else { if (widget.data is Map) { widget.data[_newKey] = _newDataValue[selectedItem]; } else { widget.data.add(_newDataValue[selectedItem]); } setState(() {}); } widget.onChanged(); } void onKeyChanged(Object key) { final val = widget.parentObject.remove(widget.keyName); widget.parentObject[key] = val; widget.onChanged(); widget.setState(() {}); } void onValueChanged(Object value) { widget.parentObject[widget.keyName] = value; widget.onChanged(); } Widget wrapWithColoredBox(Widget child, String key) { if (widget.matchedKeys[key] == true) { return ColoredBox(color: Theme.of(context).colorScheme.secondaryContainer, child: child); } return child; } String getChildSummary(_Holder widget) { final data = widget.data; var res = "{"; if (data is Map) { if (widget.expandedObjects[widget.allParents.toString()] ?? false) return ""; final content = data as Map; //res += "${data.length}"; if (content["type"] != null) { res += "${content["type"]}"; } if (content["tag"] != null) { res += " [${content["tag"]}]"; } else { final d = "$content"; res += " [${d.substring(0, min(20, d.length))}...]"; } } else if (data is List) { final content = data as List; res += "${content.length}"; } return res + "}"; } @override Widget build(BuildContext context) { if (widget.data is Map) { final mapWidget = []; final widgetData = widget.data as Map; final List keys = widgetData.keys.toList(); for (var key in keys) { mapWidget.add(_Holder( key: Key(key), data: widget.data[key], keyName: key, onChanged: widget.onChanged, parentObject: widget.data, paddingLeft: widget.paddingLeft + _space, setState: setState, matchedKeys: widget.matchedKeys, allParents: [...widget.allParents, key], expandedObjects: widget.expandedObjects, )); } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: _rowHeight, child: Row( children: [ const SizedBox(width: _expandIconWidth), if (_enableMoreOptions) _Options(onSelected, widget.getKeyPath()), SizedBox(width: widget.paddingLeft), InkWell( hoverColor: Colors.transparent, splashColor: Colors.transparent, onTap: _toggleState, child: isExpanded ? _downArrow : _rightArrow, ), const SizedBox(width: _expandIconWidth), if (_enableKeyEdit && widget.parentObject is! List) ...[ _ReplaceTextWithField( key: Key(widget.keyName.toString()), initialValue: widget.keyName, isKey: true, onChanged: onKeyChanged, setState: setState, isHighlighted: widget.matchedKeys["${widget.keyName}"] == true, ), _textSpacer, Text( getChildSummary(widget), style: _textStyle, ), ] else InkWell( hoverColor: Colors.transparent, splashColor: Colors.transparent, onTap: _toggleState, child: Row( mainAxisSize: MainAxisSize.min, children: [ wrapWithColoredBox( Text("${widget.keyName}", style: _textStyle), "${widget.keyName}", ), _textSpacer, Text(getChildSummary(widget), style: _textStyle), ], ), ), ], ), ), if (isExpanded) Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: mapWidget, ), ], ); } else if (widget.data is List) { final listWidget = []; final widgetData = widget.data as List; for (int i = 0; i < widgetData.length; i++) { listWidget.add(_Holder( key: Key("$i"), keyName: i, data: widgetData[i], onChanged: widget.onChanged, parentObject: widget.data, paddingLeft: widget.paddingLeft + _space, setState: setState, matchedKeys: widget.matchedKeys, allParents: [...widget.allParents, i], expandedObjects: widget.expandedObjects, )); } return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: _rowHeight, child: Row( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: _expandIconWidth), if (_enableMoreOptions) _Options(onSelected, widget.getKeyPath()), SizedBox(width: widget.paddingLeft), InkWell( hoverColor: Colors.transparent, splashColor: Colors.transparent, onTap: _toggleState, child: isExpanded ? _downArrow : _rightArrow, ), const SizedBox(width: _expandIconWidth), if (_enableKeyEdit && widget.parentObject is! List) ...[ _ReplaceTextWithField( key: Key(widget.keyName.toString()), initialValue: widget.keyName, isKey: true, onChanged: onKeyChanged, setState: setState, isHighlighted: widget.matchedKeys["${widget.keyName}"] == true, ), _textSpacer, Text( "[${widget.data.length}]", style: _textStyle, ), ] else InkWell( hoverColor: Colors.transparent, splashColor: Colors.transparent, onTap: _toggleState, child: Row( mainAxisSize: MainAxisSize.min, children: [ wrapWithColoredBox( Text("${widget.keyName}", style: _textStyle), "${widget.keyName}", ), _textSpacer, Text("[${widget.data.length}]", style: _textStyle), ], ), ), ], ), ), if (isExpanded) Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: listWidget, ), ], ); } else { return SizedBox( height: _rowHeight, child: Row( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: _expandIconWidth), if (_enableMoreOptions) _Options(onSelected, widget.getKeyPath()), SizedBox( width: widget.paddingLeft + (_expandIconWidth * 2), ), Row( mainAxisSize: MainAxisSize.min, children: [ if (_enableKeyEdit) ...[ _ReplaceTextWithField( key: Key(widget.keyName.toString()), initialValue: widget.keyName, isKey: true, onChanged: onKeyChanged, setState: setState, isHighlighted: widget.matchedKeys["${widget.keyName}"] == true, ), const Text(' :', style: _textStyle), ] else Row( mainAxisSize: MainAxisSize.min, children: [ wrapWithColoredBox( Text("${widget.keyName}", style: _textStyle), "${widget.keyName}", ), _textSpacer, const Text(" :", style: _textStyle), ], ), _textSpacer, if (_enableValueEdit) ...[ _ReplaceTextWithField( key: UniqueKey(), initialValue: widget.data, keyPath: widget.getKeyPath(), onChanged: onValueChanged, setState: setState, ), _textSpacer, ] else ...[ Text(widget.data.toString(), style: _textStyle), _textSpacer, ], ], ), ], ), ); } } } class _ReplaceTextWithField extends StatefulWidget { const _ReplaceTextWithField({super.key, required this.initialValue, required this.onChanged, required this.setState, this.isKey = false, this.isHighlighted = false, this.keyPath = ""}); final String keyPath; final dynamic initialValue; final bool isKey; final ValueChanged onChanged; final StateSetter setState; final bool isHighlighted; @override State<_ReplaceTextWithField> createState() => _ReplaceTextWithFieldState(); } class _ReplaceTextWithFieldState extends State<_ReplaceTextWithField> { late final _focusNode = FocusNode(); bool _isFocused = false; bool _value = false; String _text = ""; late final BoxConstraints _constraints; void handleChange() { if (!_focusNode.hasFocus) { _text = _text.trim(); final val = num.tryParse(_text); if (val == null) { widget.onChanged(_text); } else { widget.onChanged(val); } setState(() { _isFocused = false; }); } } Widget wrapWithColoredBox(String keyName) { if (widget.isHighlighted) { return ColoredBox( color: Theme.of(context).colorScheme.errorContainer, child: Text(keyName, style: _textStyle), ); } return Text(keyName, style: _textStyle); } @override void initState() { super.initState(); if (widget.initialValue is bool) { _value = widget.initialValue as bool; } else { if (widget.initialValue == _newKey) { _text = ""; _isFocused = true; _focusNode.requestFocus(); } else { _text = widget.initialValue.toString(); } } if (widget.isKey) { _constraints = const BoxConstraints(minWidth: 20, maxWidth: 100); } else if (widget.initialValue is num) { _constraints = const BoxConstraints(minWidth: 20, maxWidth: 80); } else { _constraints = const BoxConstraints(minWidth: 20, maxWidth: 400); } _focusNode.addListener(handleChange); } @override void dispose() { _focusNode.removeListener(handleChange); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (possibleValues.containsKey(widget.keyPath)) { final options = possibleValues[widget.keyPath]!; return Row( mainAxisSize: MainAxisSize.min, children: [ Transform.scale( scale: 0.75, child: DropdownButton( hint: Text('Select ${widget.keyPath.replaceAll("config.outbounds", "")}'), value: _text, icon: Icon(Icons.arrow_downward), iconSize: 24, elevation: 16, underline: Container( height: 2, ), onChanged: (String? newValue) { widget.onChanged(newValue!); _text = newValue; setState(() { _text = newValue; }); }, items: options.map>((String value) { return DropdownMenuItem( value: value, child: Text(value), ); }).toList(), ), ), ], ); } else if (widget.initialValue is bool) { return Row( mainAxisSize: MainAxisSize.min, children: [ Transform.scale( scale: 0.75, child: Checkbox( visualDensity: const VisualDensity(horizontal: -4, vertical: -4), value: _value, onChanged: (value) { widget.onChanged(value!); setState(() { _value = value; }); }, ), ), Text(_value.toString(), style: _textStyle), ], ); } else { if (_isFocused) { return TextFormField( initialValue: _text, focusNode: _focusNode, onChanged: (value) => _text = value, autocorrect: false, cursorWidth: 1, style: _textStyle, cursorHeight: 12, decoration: InputDecoration( constraints: _constraints, border: InputBorder.none, fillColor: Colors.transparent, filled: true, isDense: true, contentPadding: const EdgeInsets.all(3), focusedBorder: const OutlineInputBorder( borderRadius: BorderRadius.zero, borderSide: BorderSide(width: 0.3), ), ), ); } else { return InkWell( onTap: () { setState(() { _isFocused = true; }); _focusNode.requestFocus(); }, mouseCursor: WidgetStateMouseCursor.textable, child: widget.initialValue is String && _text.isEmpty ? const SizedBox(width: 400, height: 18) : wrapWithColoredBox(_text), ); } } } } class _Options extends StatelessWidget { const _Options(this.onSelected, this.keyPath); final String keyPath; final void Function(_OptionItems) onSelected; @override Widget build(BuildContext context) { return PopupMenuButton<_OptionItems>( tooltip: 'Add new object', padding: EdgeInsets.zero, onSelected: onSelected, itemBuilder: (context) { return >[ if (keyPath != "config" && T == Map) const _PopupMenuWidget(Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 5), Icon(Icons.add), SizedBox(width: 10), Text("Insert", style: TextStyle(fontSize: 14)), ], )), if (keyPath != "config" && T == List) const _PopupMenuWidget(Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox(width: 5), Icon(Icons.add), SizedBox(width: 10), Text("Append", style: TextStyle(fontSize: 14)), ], )), if (keyPath != "config" && (T == Map || T == List)) ...[ if (keyPath == "config.outbounds" && T == List) ...[ for (final String key in protocolSchemaValues.keys) ...{ PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: _popupMenuItemPadding), value: key, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.data_object), SizedBox(width: 10), Text(key, style: TextStyle(fontSize: 14)), ], ), ), }, const PopupMenuDivider(height: 1), ], if (T == Map) ...[ for (final String key in exampleSchemaValues.keys) ...{ if (keyPath == key) for (final String key2 in exampleSchemaValues[key]!.keys) ...{ PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: _popupMenuItemPadding), value: key + "___" + key2, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.data_object), SizedBox(width: 10), Text(key2, style: TextStyle(fontSize: 14)), ], ), ), }, const PopupMenuDivider(height: 1), }, ], if (keyPath != "config" && !(T == List && keyPath == "config.outbounds")) ...[ const PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: _popupMenuItemPadding), value: "string", child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.abc), SizedBox(width: 10), Text("String", style: TextStyle(fontSize: 14)), ], ), ), const PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: _popupMenuItemPadding), value: "num", child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.onetwothree), SizedBox(width: 10), Text("Number", style: TextStyle(fontSize: 14)), ], ), ), const PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: _popupMenuItemPadding), value: "bool", child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_rounded), SizedBox(width: 10), Text("Boolean", style: TextStyle(fontSize: 14)), ], ), ), const PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: _popupMenuItemPadding), value: "map", child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.data_object), SizedBox(width: 10), Text("object", style: TextStyle(fontSize: 14)), ], ), ), const PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: _popupMenuItemPadding), value: "list", child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.data_array), SizedBox(width: 10), Text("List", style: TextStyle(fontSize: 14)), ], ), ), ], ], const PopupMenuDivider(height: 1), if (keyPath != "config" && !(T == List && keyPath == "config.outbounds")) const PopupMenuItem<_OptionItems>( height: _popupMenuHeight, padding: EdgeInsets.only(left: 5), value: "delete", child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.delete), SizedBox(width: 10), Text("Delete", style: TextStyle(fontSize: 14)), ], ), ), ]; }, child: _options, ); } } class _PopupMenuWidget extends PopupMenuEntry { const _PopupMenuWidget(this.child); final Widget child; @override final double height = _popupMenuHeight; @override bool represents(_) => false; @override State<_PopupMenuWidget> createState() => _PopupMenuWidgetState(); } class _PopupMenuWidgetState extends State<_PopupMenuWidget> { @override Widget build(BuildContext context) { return widget.child; } } class _SearchField extends StatelessWidget { final ValueChanged onChanged; final ValueChanged<_SearchActions> onAction; const _SearchField(this.onChanged, this.onAction); @override Widget build(BuildContext context) { return ColoredBox( color: Theme.of(context).searchBarTheme.backgroundColor?.resolve({}) ?? Colors.black, child: Row( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 2), const Icon(CupertinoIcons.search, size: 20), const SizedBox(width: 5), TextField( onChanged: onChanged, autocorrect: false, autofocus: true, cursorWidth: 1, // style: _textStyle, cursorHeight: 12, decoration: InputDecoration( hintText: "Search", hintStyle: Theme.of(context).textTheme.bodySmall, constraints: BoxConstraints(maxWidth: 100), border: InputBorder.none, // fillColor: Colors.transparent, // filled: true, isDense: true, contentPadding: EdgeInsets.all(3), focusedBorder: InputBorder.none, // hoverColor: Colors.transparent, ), ), const SizedBox(width: 5), InkWell( onTap: () { onAction(_SearchActions.next); }, child: const Tooltip( message: 'Next', child: Icon( CupertinoIcons.arrowtriangle_down_fill, size: 20, ), ), ), const SizedBox(width: 2), InkWell( onTap: () { onAction(_SearchActions.prev); }, child: const Tooltip( message: 'Previous', child: Icon( CupertinoIcons.arrowtriangle_up_fill, size: 20, ), ), ), const SizedBox(width: 5), ], ), ); } } List _getSpace(int count) { if (count == 0) return ['', ' ']; String space = ''; for (int i = 0; i < count; i++) { space += ' '; } return [space, '$space ']; } String _stringifyData(data, int spacing, [bool isLast = false]) { String str = ''; final spaceList = _getSpace(spacing); final objectSpace = spaceList[0]; final dataSpace = spaceList[1]; if (data is Map) { str += '$objectSpace{'; str += '\n'; final keys = data.keys.toList(); for (int i = 0; i < keys.length; i++) { str += '$dataSpace"${keys[i]}": ${_stringifyData(data[keys[i]], spacing + 1, i == keys.length - 1)}'; str += '\n'; } str += '$objectSpace}'; if (!isLast) str += ','; } else if (data is List) { str += '$objectSpace['; str += '\n'; for (int i = 0; i < data.length; i++) { final item = data[i]; if (item is Map || item is List) { str += _stringifyData(item, spacing + 1, i == data.length - 1); } else { str += '$dataSpace${_stringifyData(item, spacing + 1, i == data.length - 1)}'; } str += '\n'; } str += '$objectSpace]'; if (!isLast) str += ','; } else { if (data is String) { str = '"$data"'; } else { str = '$data'; } if (!isLast) str += ','; } return str; }