mirror of
https://github.com/wanghongenpin/proxypin.git
synced 2026-04-02 07:55:11 +08:00
488 lines
16 KiB
Dart
488 lines
16 KiB
Dart
/*
|
|
* 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 = <GlobalKey>[];
|
|
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<int> matchTotalCount, required List<GlobalKey> matchKeys}) {
|
|
if (content is List) {
|
|
return JsonArrayViewer(content,
|
|
colorTheme: colorTheme,
|
|
searchController: searchController,
|
|
matchTotalCount: matchTotalCount,
|
|
matchKeys: matchKeys);
|
|
} else if (content is Map<String, dynamic>) {
|
|
return JsonObjectViewer(content,
|
|
colorTheme: colorTheme,
|
|
searchController: searchController,
|
|
matchTotalCount: matchTotalCount,
|
|
matchKeys: matchKeys);
|
|
} else {
|
|
return SelectableText(showCursor: true, content?.toString() ?? '');
|
|
}
|
|
}
|
|
|
|
void scrollToMatch(List<GlobalKey> 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<String, dynamic> jsonObj;
|
|
final bool notRoot;
|
|
final SearchTextController? searchController;
|
|
final ValueWrap<int> matchTotalCount;
|
|
final List<GlobalKey> 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<JsonObjectViewer> {
|
|
Map<String, bool> 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<Widget> _getList() {
|
|
List<Widget> 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: <Widget>[
|
|
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<int> matchTotalCount,
|
|
required List<GlobalKey> 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<dynamic> jsonArray;
|
|
final bool notRoot;
|
|
final SearchTextController? searchController;
|
|
final ValueWrap<int> matchTotalCount;
|
|
final List<GlobalKey> matchKeys;
|
|
|
|
const JsonArrayViewer(this.jsonArray,
|
|
{super.key,
|
|
this.notRoot = false,
|
|
required this.colorTheme,
|
|
this.searchController,
|
|
required this.matchTotalCount,
|
|
required this.matchKeys});
|
|
|
|
@override
|
|
State<JsonArrayViewer> createState() => _JsonArrayViewerState();
|
|
}
|
|
|
|
class _JsonArrayViewerState extends State<JsonArrayViewer> {
|
|
late List<bool> 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<Widget> _getList() {
|
|
List<Widget> list = [];
|
|
int i = 0;
|
|
for (dynamic content in widget.jsonArray) {
|
|
list.add(Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
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<int> matchTotalCount,
|
|
required List<GlobalKey> 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<InlineSpan> _highlightText(String text, TextStyle textStyle,
|
|
{SearchTextController? searchController,
|
|
required ColorTheme colorTheme,
|
|
required ValueWrap<int> matchTotalCount,
|
|
required List<GlobalKey> 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 = <InlineSpan>[];
|
|
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;
|
|
}
|