/* * Copyright 2023 Hongen Wang All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:proxypin/ui/component/json/theme.dart'; import 'package:proxypin/ui/component/json/toast.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/utils/platform.dart'; import '../search/search_controller.dart'; class JsonViewer extends StatelessWidget { final dynamic jsonObj; final ColorTheme colorTheme; final SearchTextController? searchController; const JsonViewer(this.jsonObj, {super.key, required this.colorTheme, this.searchController}); @override Widget build(BuildContext context) { final matchKeys = []; if (searchController == null) { return DefaultTextStyle.merge( style: const TextStyle(fontWeight: FontWeight.w600), child: getContentWidget(jsonObj, matchTotalCount: ValueWrap.of(0), matchKeys: matchKeys)); } return AnimatedBuilder( animation: searchController ?? ValueNotifier(0), builder: (context, child) { final matchTotalCount = ValueWrap.of(0); matchKeys.clear(); final contentWidget = DefaultTextStyle.merge( style: const TextStyle(fontWeight: FontWeight.w600), child: getContentWidget(jsonObj, matchTotalCount: matchTotalCount, matchKeys: matchKeys)); WidgetsBinding.instance.addPostFrameCallback((_) { searchController?.updateMatchCount(matchTotalCount.get()!); scrollToMatch(matchKeys); }); return contentWidget; }); } Widget getContentWidget(dynamic content, {required ValueWrap matchTotalCount, required List matchKeys}) { if (content is List) { return JsonArrayViewer(content, colorTheme: colorTheme, searchController: searchController, matchTotalCount: matchTotalCount, matchKeys: matchKeys); } else if (content is Map) { return JsonObjectViewer(content, colorTheme: colorTheme, searchController: searchController, matchTotalCount: matchTotalCount, matchKeys: matchKeys); } else { return SelectableText(showCursor: true, content?.toString() ?? ''); } } void scrollToMatch(List matchKeys) { if (searchController != null && matchKeys.isNotEmpty) { final currentIndex = searchController!.currentMatchIndex.value; if (currentIndex >= 0 && currentIndex < matchKeys.length) { final key = matchKeys[currentIndex]; final context = key.currentContext; if (context != null) { Scrollable.ensureVisible( context, duration: const Duration(milliseconds: 300), alignment: 0.5, // 高亮项在视图中的位置 ); } } } } } class JsonObjectViewer extends StatefulWidget { final ColorTheme colorTheme; final Map jsonObj; final bool notRoot; final SearchTextController? searchController; final ValueWrap matchTotalCount; final List matchKeys; const JsonObjectViewer(this.jsonObj, {super.key, this.notRoot = false, required this.colorTheme, this.searchController, required this.matchTotalCount, required this.matchKeys}); @override JsonObjectViewerState createState() => JsonObjectViewerState(); } class JsonObjectViewerState extends State { Map openFlag = {}; @override void didUpdateWidget(covariant JsonObjectViewer oldWidget) { super.didUpdateWidget(oldWidget); openFlag = {}; } @override Widget build(BuildContext context) { if (widget.notRoot) { return Container( padding: const EdgeInsets.only(left: 14.0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _getList()), ); } return Column(crossAxisAlignment: CrossAxisAlignment.start, children: _getList()); } List _getList() { List list = []; for (MapEntry entry in widget.jsonObj.entries) { if (openFlag[entry.key] == null) { openFlag[entry.key] = widget.notRoot == false && _isExtensible(entry.value); } list.add(Row( children: [ getKeyWidget(entry), Text(':', style: TextStyle(color: widget.colorTheme.colon)), const SizedBox(width: 3), _copyValue( context, _getValueWidget(entry.value, widget.colorTheme, searchController: widget.searchController, matchTotalCount: widget.matchTotalCount, matchKeys: widget.matchKeys), entry.value), ], )); list.add(const SizedBox(height: 4)); if ((openFlag[entry.key] ?? false) && entry.value != null) { list.add(getContentWidget(entry.value, widget.colorTheme, searchController: widget.searchController, matchTotalCount: widget.matchTotalCount, matchKeys: widget.matchKeys)); } } return list; } // key Widget getKeyWidget(MapEntry entry) { final keyText = entry.key; final keyWidget = Container( constraints: BoxConstraints(maxWidth: 350), child: SelectableText.rich( showCursor: true, TextSpan( children: _highlightText(keyText, TextStyle(color: widget.colorTheme.propertyKey), searchController: widget.searchController, colorTheme: widget.colorTheme, matchTotalCount: widget.matchTotalCount, matchKeys: widget.matchKeys)))); //是否有子层级 if (_isExtensible(entry.value)) { return InkWell( onTap: () { setState(() { openFlag[entry.key] = !(openFlag[entry.key] ?? false); }); }, child: Row( mainAxisAlignment: MainAxisAlignment.start, children: [ (openFlag[entry.key] ?? false) ? const Icon(Icons.keyboard_arrow_down, size: 18) : const Icon(Icons.keyboard_arrow_right, size: 18), keyWidget, ], )); } return Row(children: [ const Icon(Icons.keyboard_arrow_right, color: Color.fromARGB(0, 0, 0, 0), size: 18), keyWidget, ]); } static Widget getContentWidget(dynamic content, ColorTheme colorTheme, {SearchTextController? searchController, required ValueWrap matchTotalCount, required List matchKeys}) { if (content is List) { return JsonArrayViewer(content, notRoot: true, colorTheme: colorTheme, searchController: searchController, matchTotalCount: matchTotalCount, matchKeys: matchKeys); } else { return JsonObjectViewer(content, notRoot: true, colorTheme: colorTheme, searchController: searchController, matchTotalCount: matchTotalCount, matchKeys: matchKeys); } } } class JsonArrayViewer extends StatefulWidget { final ColorTheme colorTheme; final List jsonArray; final bool notRoot; final SearchTextController? searchController; final ValueWrap matchTotalCount; final List matchKeys; const JsonArrayViewer(this.jsonArray, {super.key, this.notRoot = false, required this.colorTheme, this.searchController, required this.matchTotalCount, required this.matchKeys}); @override State createState() => _JsonArrayViewerState(); } class _JsonArrayViewerState extends State { late List openFlag; @override Widget build(BuildContext context) { if (widget.notRoot) { return Container( padding: const EdgeInsets.only(left: 14.0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _getList())); } return Column(crossAxisAlignment: CrossAxisAlignment.start, children: _getList()); } @override void initState() { super.initState(); openFlag = List.filled(widget.jsonArray.length, false); } List _getList() { List list = []; int i = 0; for (dynamic content in widget.jsonArray) { list.add(Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ getKeyWidget(content, i), Text(':', style: TextStyle(color: widget.colorTheme.colon)), const SizedBox(width: 3), _copyValue( context, _getValueWidget(content, widget.colorTheme, searchController: widget.searchController, matchTotalCount: widget.matchTotalCount, matchKeys: widget.matchKeys), content) ], )); list.add(const SizedBox(height: 4)); if (openFlag[i]) { list.add(JsonObjectViewerState.getContentWidget(content, widget.colorTheme, searchController: widget.searchController, matchTotalCount: widget.matchTotalCount, matchKeys: widget.matchKeys)); } i++; } return list; } // key Widget getKeyWidget(dynamic content, int index) { //是否有子层级 if (_isExtensible(content)) { return InkWell( onTap: () { setState(() { openFlag[index] = !(openFlag[index]); }); }, child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ openFlag[index] ? const Icon(Icons.keyboard_arrow_down, size: 18) : const Icon(Icons.keyboard_arrow_right, size: 18), Text('[$index]', style: TextStyle(color: widget.colorTheme.propertyKey)), ], )); } return Row(children: [ const Icon(Icons.arrow_right, color: Color.fromARGB(0, 0, 0, 0), size: 18), Text('[$index]', style: TextStyle(color: widget.colorTheme.propertyKey)), ]); } } Widget _getValueWidget(dynamic value, ColorTheme colorTheme, {SearchTextController? searchController, required ValueWrap matchTotalCount, required List matchKeys}) { String valueStr; TextStyle style; if (value == null) { valueStr = 'null'; style = TextStyle(color: colorTheme.keyword); } else if (value is num) { valueStr = value.toString(); style = TextStyle(color: colorTheme.number); } else if (value is String) { valueStr = '"$value"'; style = TextStyle(color: colorTheme.string); } else if (value is bool) { valueStr = value.toString(); style = TextStyle(color: colorTheme.keyword); } else if (value is List) { if (value.isEmpty) { valueStr = 'Array[0]'; style = const TextStyle(); } else { valueStr = 'Array<${_getTypeName(value[0])}>[${value.length}]'; style = const TextStyle(); } } else { valueStr = 'Object'; style = const TextStyle(fontSize: 13); } if (searchController?.shouldSearch() == true) { return SelectableText.rich( showCursor: true, TextSpan( children: _highlightText(valueStr, style, searchController: searchController, colorTheme: colorTheme, matchTotalCount: matchTotalCount, matchKeys: matchKeys)), ); } return SelectableText(showCursor: true, valueStr, style: style); } List _highlightText(String text, TextStyle textStyle, {SearchTextController? searchController, required ColorTheme colorTheme, required ValueWrap matchTotalCount, required List matchKeys}) { if (searchController == null || searchController.shouldSearch() == false) { return [TextSpan(text: text, style: textStyle)]; } final pattern = searchController.value.pattern; final regex = searchController.value.isRegExp ? RegExp(pattern, caseSensitive: searchController.value.isCaseSensitive) : RegExp(RegExp.escape(pattern), caseSensitive: searchController.value.isCaseSensitive); final spans = []; int start = 0; var allMatches = regex.allMatches(text).toList(); final currentIndex = searchController.currentMatchIndex.value; for (int i = 0; i < allMatches.length; i++) { final match = allMatches[i]; if (match.start > start) { spans.add(TextSpan(text: text.substring(start, match.start), style: textStyle)); } // 为每个高亮项分配一个 GlobalKey final key = GlobalKey(); matchKeys.add(key); spans.add(WidgetSpan( alignment: PlaceholderAlignment.middle, baseline: TextBaseline.ideographic, child: Text( key: key, text.substring(match.start, match.end), style: textStyle.copyWith( backgroundColor: matchTotalCount.get() == currentIndex ? colorTheme.searchMatchCurrentColor : colorTheme.searchMatchColor, )), )); start = match.end; matchTotalCount.set(matchTotalCount.get()! + 1); } if (start < text.length) { spans.add(TextSpan(text: text.substring(start), style: textStyle)); } return spans; } ///获取值的类型 String _getTypeName(dynamic content) { if (content is int) { return 'int'; } else if (content is String) { return 'String'; } else if (content is bool) { return 'bool'; } else if (content is double) { return 'double'; } else if (content is List) { return 'List'; } return 'Object'; } /// 复制值 Widget _copyValue(BuildContext context, Widget child, Object? value) { return Flexible( child: GestureDetector( onSecondaryTapDown: (details) => showJsonCopyMenu(context, details.globalPosition, value), onTapDown: Platforms.isDesktop() ? null : (details) => showJsonCopyMenu(context, details.globalPosition, value), child: child)); } void showJsonCopyMenu(BuildContext context, Offset position, Object? value) { AppLocalizations localizations = AppLocalizations.of(context)!; //显示复制菜单 showMenu( context: context, position: RelativeRect.fromLTRB(position.dx, position.dy, position.dx, position.dy), items: [ PopupMenuItem( height: 30, child: Text(localizations.copy), onTap: () { if (value == null) { return; } Clipboard.setData(ClipboardData(text: value is String ? value : jsonEncode(value))) .then((value) => Toast.show(localizations.copied, context)); }) ]); } /// 是否可展开 bool _isExtensible(dynamic content) { if (content == null) { return false; } else if (content is int) { return false; } else if (content is String) { return false; } else if (content is bool) { return false; } else if (content is double) { return false; } return true; }