mirror of
https://github.com/foss42/apidash.git
synced 2025-05-21 08:16:29 +08:00
482 lines
18 KiB
Dart
482 lines
18 KiB
Dart
import 'package:apidash_core/apidash_core.dart';
|
|
import 'package:apidash_design_system/apidash_design_system.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:json_data_explorer/json_data_explorer.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
import 'package:url_launcher/url_launcher_string.dart';
|
|
import '../consts.dart';
|
|
import '../utils/ui_utils.dart';
|
|
import "snackbars.dart";
|
|
import 'field_json_search.dart';
|
|
|
|
class JsonPreviewerColor {
|
|
const JsonPreviewerColor._();
|
|
|
|
static const Color lightRootInfoBox = Color(0x80E1E1E1);
|
|
static const Color lightRootKeyText = Colors.black;
|
|
static const Color lightPropertyKeyText = Colors.black;
|
|
static const Color lightKeySearchHighlightText = Colors.black;
|
|
static const Color lightKeySearchHighlightBackground = Color(0xFFFFEDAD);
|
|
static const Color lightFocusedKeySearchHighlightText = Colors.black;
|
|
static const Color lightFocusedKeySearchHighlightBackground =
|
|
Color(0xFFF29D0B);
|
|
static const Color lightValueText = Color(0xffc41a16);
|
|
static const Color lightValueSearchHighlightText = Color(0xffc41a16);
|
|
static const Color lightValueNum = Color(0xff3F6E74);
|
|
static const Color lightValueBool = Color(0xff1c00cf);
|
|
static const Color lightValueSearchHighlightBackground = Color(0xFFFFEDAD);
|
|
static const Color lightFocusedValueSearchHighlightText = Colors.black;
|
|
static const Color lightFocusedValueSearchHighlightBackground =
|
|
Color(0xFFF29D0B);
|
|
static const Color lightIndentationLineColor =
|
|
Color.fromARGB(255, 213, 213, 213);
|
|
static const Color lightHighlightColor = Color(0xFFF1F1F1);
|
|
|
|
// Dark colors
|
|
static const Color darkRootInfoBox = Color.fromARGB(255, 83, 13, 19);
|
|
static const Color darkRootKeyText = Color(0xffd6deeb);
|
|
static const Color darkPropertyKeyText = Color(0xffd6deeb);
|
|
static const Color darkKeySearchHighlightText = Color(0xffd6deeb);
|
|
static const Color darkKeySearchHighlightBackground = Color(0xff9b703f);
|
|
static const Color darkFocusedKeySearchHighlightText = Color(0xffd6deeb);
|
|
static const Color darkFocusedKeySearchHighlightBackground =
|
|
Color(0xffc41a16);
|
|
static const Color darkValueText = Color(0xffecc48d);
|
|
static const Color darkValueSearchHighlightText = Color(0xffecc48d);
|
|
static const Color darkValueNum = Color(0xffaddb67);
|
|
static const Color darkValueBool = Color(0xff82aaff);
|
|
static const Color darkValueSearchHighlightBackground = Color(0xff9b703f);
|
|
static const Color darkFocusedValueSearchHighlightText = Color(0xffd6deeb);
|
|
static const Color darkFocusedValueSearchHighlightBackground =
|
|
Color(0xffc41a16);
|
|
static const Color darkIndentationLineColor =
|
|
Color.fromARGB(255, 119, 119, 119);
|
|
static const Color darkHighlightColor = Color.fromARGB(255, 55, 55, 55);
|
|
}
|
|
|
|
final dataExplorerThemeLight = DataExplorerTheme(
|
|
rootKeyTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.lightRootKeyText,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
propertyKeyTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.lightPropertyKeyText,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
keySearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.lightKeySearchHighlightText,
|
|
backgroundColor: JsonPreviewerColor.lightKeySearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
focusedKeySearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.lightFocusedKeySearchHighlightText,
|
|
backgroundColor:
|
|
JsonPreviewerColor.lightFocusedKeySearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
valueTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.lightValueText,
|
|
),
|
|
valueSearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.lightValueSearchHighlightText,
|
|
backgroundColor: JsonPreviewerColor.lightValueSearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
focusedValueSearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.lightFocusedValueSearchHighlightText,
|
|
backgroundColor:
|
|
JsonPreviewerColor.lightFocusedValueSearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
indentationLineColor: JsonPreviewerColor.lightIndentationLineColor,
|
|
highlightColor: JsonPreviewerColor.lightHighlightColor,
|
|
);
|
|
|
|
final dataExplorerThemeDark = DataExplorerTheme(
|
|
rootKeyTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.darkRootKeyText,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
propertyKeyTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.darkPropertyKeyText,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
keySearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.darkKeySearchHighlightText,
|
|
backgroundColor: JsonPreviewerColor.darkKeySearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
focusedKeySearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.darkFocusedKeySearchHighlightText,
|
|
backgroundColor: JsonPreviewerColor.darkFocusedKeySearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
valueTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.darkValueText,
|
|
),
|
|
valueSearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.darkValueSearchHighlightText,
|
|
backgroundColor: JsonPreviewerColor.darkValueSearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
focusedValueSearchHighlightTextStyle: kCodeStyle.copyWith(
|
|
color: JsonPreviewerColor.darkFocusedValueSearchHighlightText,
|
|
backgroundColor:
|
|
JsonPreviewerColor.darkFocusedValueSearchHighlightBackground,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
indentationLineColor: JsonPreviewerColor.darkIndentationLineColor,
|
|
highlightColor: JsonPreviewerColor.darkHighlightColor,
|
|
);
|
|
|
|
class JsonPreviewer extends StatefulWidget {
|
|
const JsonPreviewer({
|
|
super.key,
|
|
required this.code,
|
|
});
|
|
final dynamic code;
|
|
|
|
@override
|
|
State<JsonPreviewer> createState() => _JsonPreviewerState();
|
|
}
|
|
|
|
class _JsonPreviewerState extends State<JsonPreviewer> {
|
|
final searchController = TextEditingController();
|
|
final itemScrollController = ItemScrollController();
|
|
final DataExplorerStore store = DataExplorerStore();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
store.buildNodes(widget.code, areAllCollapsed: true);
|
|
store.expandAll();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(JsonPreviewer oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.code != widget.code) {
|
|
store.buildNodes(widget.code, areAllCollapsed: true);
|
|
store.expandAll();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var sm = ScaffoldMessenger.of(context);
|
|
return ChangeNotifierProvider.value(
|
|
value: store,
|
|
child: Consumer<DataExplorerStore>(
|
|
builder: (context, state, child) {
|
|
return LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
var maxRootNodeWidth =
|
|
getJsonPreviewerMaxRootNodeWidth(constraints.maxWidth);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: constraints.minWidth > kMinWindowSize.width
|
|
? [
|
|
TextButton(
|
|
onPressed: () async {
|
|
await _copy(
|
|
kJsonEncoder.convert(widget.code), sm);
|
|
},
|
|
child: const Text(
|
|
'Copy',
|
|
style: kTextStyleButtonSmall,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: state.areAllExpanded()
|
|
? null
|
|
: state.expandAll,
|
|
child: const Text(
|
|
'Expand All',
|
|
style: kTextStyleButtonSmall,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: state.areAllCollapsed()
|
|
? null
|
|
: state.collapseAll,
|
|
child: const Text(
|
|
'Collapse All',
|
|
style: kTextStyleButtonSmall,
|
|
),
|
|
),
|
|
]
|
|
: [
|
|
IconButton(
|
|
tooltip: "Copy",
|
|
color: Theme.of(context).colorScheme.primary,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: () async {
|
|
await _copy(
|
|
kJsonEncoder.convert(widget.code), sm);
|
|
},
|
|
icon: const Icon(
|
|
Icons.copy,
|
|
size: 16,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: "Expand All",
|
|
color: Theme.of(context).colorScheme.primary,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: state.areAllExpanded()
|
|
? null
|
|
: state.expandAll,
|
|
icon: const Icon(
|
|
Icons.unfold_more,
|
|
size: 16,
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: "Collapse All",
|
|
color: Theme.of(context).colorScheme.primary,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: state.areAllCollapsed()
|
|
? null
|
|
: state.collapseAll,
|
|
icon: const Icon(
|
|
Icons.unfold_less,
|
|
size: 16,
|
|
),
|
|
),
|
|
]),
|
|
Expanded(
|
|
child: JsonDataExplorer(
|
|
nodes: state.displayNodes,
|
|
itemScrollController: itemScrollController,
|
|
itemSpacing: 4,
|
|
rootInformationBuilder: (context, node) =>
|
|
rootInfoBox(context, node),
|
|
collapsableToggleBuilder: (context, node) =>
|
|
AnimatedRotation(
|
|
turns: node.isCollapsed ? -0.25 : 0,
|
|
duration: const Duration(milliseconds: 300),
|
|
child: const Icon(Icons.arrow_drop_down),
|
|
),
|
|
trailingBuilder: (context, node) => node.isFocused
|
|
? Padding(
|
|
padding: const EdgeInsets.only(right: 12),
|
|
child: IconButton(
|
|
padding: EdgeInsets.zero,
|
|
constraints:
|
|
const BoxConstraints(maxHeight: 18),
|
|
icon: const Icon(
|
|
Icons.copy,
|
|
size: 18,
|
|
),
|
|
onPressed: () async {
|
|
await _copy(
|
|
kJsonEncoder.convert(toJson(node)), sm);
|
|
},
|
|
),
|
|
)
|
|
: const SizedBox(),
|
|
valueStyleBuilder: (value, style) =>
|
|
valueStyleOverride(context, value, style),
|
|
theme: (Theme.of(context).brightness == Brightness.light)
|
|
? dataExplorerThemeLight
|
|
: dataExplorerThemeDark,
|
|
maxRootNodeWidth: maxRootNodeWidth,
|
|
),
|
|
),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
border: Border.all(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.surfaceContainerHighest),
|
|
borderRadius: kBorderRadius8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8.0),
|
|
child: Icon(
|
|
Icons.search,
|
|
size: 18,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: JsonSearchField(
|
|
controller: searchController,
|
|
onChanged: (term) => state.search(term),
|
|
),
|
|
),
|
|
const SizedBox(
|
|
width: 8,
|
|
),
|
|
if (state.searchResults.isNotEmpty)
|
|
Text(_searchFocusText(),
|
|
style: Theme.of(context).textTheme.bodySmall),
|
|
if (state.searchResults.isNotEmpty)
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: () {
|
|
store.focusPreviousSearchResult();
|
|
_scrollToSearchMatch();
|
|
},
|
|
icon: const Icon(Icons.arrow_drop_up),
|
|
),
|
|
if (state.searchResults.isNotEmpty)
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: () {
|
|
store.focusNextSearchResult();
|
|
_scrollToSearchMatch();
|
|
},
|
|
icon: const Icon(Icons.arrow_drop_down),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _copy(String text, ScaffoldMessengerState sm) async {
|
|
String msg;
|
|
try {
|
|
await Clipboard.setData(ClipboardData(text: text));
|
|
msg = "Copied";
|
|
} catch (e) {
|
|
msg = "An error occurred";
|
|
}
|
|
sm.hideCurrentSnackBar();
|
|
sm.showSnackBar(getSnackBar(msg));
|
|
}
|
|
|
|
PropertyOverrides valueStyleOverride(
|
|
BuildContext context,
|
|
dynamic value,
|
|
TextStyle style,
|
|
) {
|
|
TextStyle newStyle = style;
|
|
bool isUrl = false;
|
|
if (value.runtimeType.toString() == "num" ||
|
|
value.runtimeType.toString() == "double" ||
|
|
value.runtimeType.toString() == "int") {
|
|
newStyle = style.copyWith(
|
|
color: (Theme.of(context).brightness == Brightness.light)
|
|
? JsonPreviewerColor.lightValueNum
|
|
: JsonPreviewerColor.darkValueNum,
|
|
);
|
|
} else if (value.runtimeType.toString() == "bool") {
|
|
newStyle = style.copyWith(
|
|
color: (Theme.of(context).brightness == Brightness.light)
|
|
? JsonPreviewerColor.lightValueBool
|
|
: JsonPreviewerColor.darkValueBool,
|
|
);
|
|
} else {
|
|
isUrl = _valueIsUrl(value);
|
|
if (isUrl) {
|
|
newStyle = style.copyWith(
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: (Theme.of(context).brightness == Brightness.light)
|
|
? JsonPreviewerColor.lightValueText
|
|
: JsonPreviewerColor.darkValueText,
|
|
);
|
|
}
|
|
}
|
|
|
|
return PropertyOverrides(
|
|
style: newStyle,
|
|
onTap: isUrl ? () => _launchUrl(value as String) : null,
|
|
);
|
|
}
|
|
|
|
DecoratedBox rootInfoBox(BuildContext context, NodeViewModelState node) {
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: (Theme.of(context).brightness == Brightness.light)
|
|
? JsonPreviewerColor.lightRootInfoBox
|
|
: JsonPreviewerColor.darkRootInfoBox,
|
|
borderRadius: const BorderRadius.all(Radius.circular(2)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 4,
|
|
vertical: 2,
|
|
),
|
|
child: SelectableText(
|
|
node.isClass ? '{${node.childrenCount}}' : '[${node.childrenCount}]',
|
|
style: kCodeStyle,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _searchFocusText() =>
|
|
'${store.focusedSearchResultIndex + 1} of ${store.searchResults.length}';
|
|
|
|
void _scrollToSearchMatch() {
|
|
final index = store.displayNodes.indexOf(store.focusedSearchResult.node);
|
|
if (index != -1) {
|
|
itemScrollController.scrollTo(
|
|
index: index,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOutCubic,
|
|
);
|
|
}
|
|
}
|
|
|
|
bool _valueIsUrl(dynamic value) {
|
|
if (value is String) {
|
|
return Uri.tryParse(value)?.hasAbsolutePath ?? false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Future _launchUrl(String url) {
|
|
return launchUrlString(url);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
dynamic toJson(
|
|
NodeViewModelState node,
|
|
) {
|
|
dynamic res;
|
|
if (node.isRoot) {
|
|
if (node.isClass) {
|
|
res = {};
|
|
for (var i in node.children) {
|
|
res.addAll(toJson(i));
|
|
}
|
|
}
|
|
if (node.isArray) {
|
|
res = [];
|
|
for (var i in node.children) {
|
|
res.add(toJson(i));
|
|
}
|
|
}
|
|
} else {
|
|
res = node.value;
|
|
}
|
|
|
|
if (node.parent != null && node.parent!.isArray) {
|
|
return res;
|
|
} else {
|
|
return {node.key: res};
|
|
}
|
|
}
|