diff --git a/lib/consts.dart b/lib/consts.dart index 004f8a71..f9fc453d 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -312,6 +312,10 @@ const kCodeRawBodyViewOptions = [ResponseBodyView.code, ResponseBodyView.raw]; const kPreviewBodyViewOptions = [ ResponseBodyView.preview, ]; +const kPreviewRawBodyViewOptions = [ + ResponseBodyView.preview, + ResponseBodyView.raw +]; const kPreviewCodeRawBodyViewOptions = [ ResponseBodyView.preview, ResponseBodyView.code, @@ -322,7 +326,7 @@ const Map>> kResponseBodyViewOptions = { kTypeApplication: { kSubTypeDefaultViewOptions: kNoRawBodyViewOptions, - kSubTypeJson: kCodeRawBodyViewOptions, + kSubTypeJson: kPreviewRawBodyViewOptions, kSubTypeOctetStream: kNoBodyViewOptions, kSubTypePdf: kPreviewBodyViewOptions, kSubTypeSql: kCodeRawBodyViewOptions, diff --git a/lib/widgets/json_previewer.dart b/lib/widgets/json_previewer.dart new file mode 100644 index 00000000..316387f9 --- /dev/null +++ b/lib/widgets/json_previewer.dart @@ -0,0 +1,403 @@ +import 'dart:convert'; +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 "snackbars.dart"; +import 'textfields.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 String code; + + @override + State createState() => _JsonPreviewerState(); +} + +class _JsonPreviewerState extends State { + final searchController = TextEditingController(); + final itemScrollController = ItemScrollController(); + final DataExplorerStore store = DataExplorerStore(); + + @override + void initState() { + super.initState(); + store.buildNodes(jsonDecode(widget.code), areAllCollapsed: true); + store.expandAll(); + } + + @override + Widget build(BuildContext context) { + var sm = ScaffoldMessenger.of(context); + return ChangeNotifierProvider.value( + value: store, + child: Consumer( + builder: (context, state, child) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () async { + await _copy(kEncoder.convert(jsonDecode(widget.code)), sm); + }, + child: const Text('Copy'), + ), + TextButton( + onPressed: state.areAllExpanded() ? null : state.expandAll, + child: const Text('Expand All'), + ), + TextButton( + onPressed: state.areAllCollapsed() ? null : state.collapseAll, + child: const Text('Collapse All'), + ), + ], + ), + 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(kEncoder.convert(toJson(node)), sm); + }, + ), + ) + : const SizedBox(), + valueStyleBuilder: (value, style) => + valueStyleOverride(context, value, style), + theme: (Theme.of(context).brightness == Brightness.light) + ? dataExplorerThemeLight + : dataExplorerThemeDark, + ), + ), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.background, + border: Border.all( + color: Theme.of(context).colorScheme.surfaceVariant), + 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 _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: Text( + 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}; + } +} diff --git a/lib/widgets/previewer.dart b/lib/widgets/previewer.dart index 3e48dc6d..fbc2b0ff 100644 --- a/lib/widgets/previewer.dart +++ b/lib/widgets/previewer.dart @@ -4,18 +4,20 @@ import 'error_message.dart'; import 'package:apidash/consts.dart'; import 'package:printing/printing.dart'; import 'uint8_audio_player.dart'; - +import 'json_previewer.dart'; class Previewer extends StatefulWidget { const Previewer({ super.key, required this.bytes, + required this.body, this.type, this.subtype, this.hasRaw = false, }); final Uint8List bytes; + final String body; final String? type; final String? subtype; final bool hasRaw; @@ -27,6 +29,11 @@ class Previewer extends StatefulWidget { class _PreviewerState extends State { @override Widget build(BuildContext context) { + if (widget.type == kTypeApplication && widget.subtype == kSubTypeJson) { + return JsonPreviewer( + code: widget.body, + ); + } if (widget.type == kTypeImage) { return Image.memory( widget.bytes, diff --git a/lib/widgets/response_widgets.dart b/lib/widgets/response_widgets.dart index 5bcd9c7d..da905749 100644 --- a/lib/widgets/response_widgets.dart +++ b/lib/widgets/response_widgets.dart @@ -453,11 +453,17 @@ class _BodySuccessState extends State { visible: currentSeg == ResponseBodyView.preview || currentSeg == ResponseBodyView.none, child: Expanded( - child: Previewer( - bytes: widget.bytes, - type: widget.mediaType.type, - subtype: widget.mediaType.subtype, - hasRaw: widget.options.contains(ResponseBodyView.raw), + child: Container( + width: double.maxFinite, + padding: kP8, + decoration: textContainerdecoration, + child: Previewer( + bytes: widget.bytes, + body: widget.body, + type: widget.mediaType.type, + subtype: widget.mediaType.subtype, + hasRaw: widget.options.contains(ResponseBodyView.raw), + ), ), ), ), diff --git a/lib/widgets/textfields.dart b/lib/widgets/textfields.dart index ed0e2cd1..ff7b7313 100644 --- a/lib/widgets/textfields.dart +++ b/lib/widgets/textfields.dart @@ -97,3 +97,25 @@ class _CellFieldState extends State { ); } } + +class JsonSearchField extends StatelessWidget { + const JsonSearchField({super.key, this.onChanged, this.controller}); + + final void Function(String)? onChanged; + final TextEditingController? controller; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + onChanged: onChanged, + style: kCodeStyle, + cursorHeight: 18, + decoration: const InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: 'Search..', + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 34c15f8f..23929f62 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -19,3 +19,4 @@ export 'snackbars.dart'; export 'markdown.dart'; export 'uint8_audio_player.dart'; export 'tabs.dart'; +export 'json_previewer.dart'; diff --git a/pubspec.lock b/pubspec.lock index 2401981c..cff0b55a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -520,6 +520,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.1" + json_data_explorer: + dependency: "direct main" + description: + name: json_data_explorer + sha256: "303a00037b23963fd01be1b2dc509f14e9db2a40f852b0ce042d7635c22fd154" + url: "https://pub.dev" + source: hosted + version: "0.1.0" json_serializable: dependency: "direct dev" description: @@ -656,6 +664,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" node_preamble: dependency: transitive description: @@ -816,6 +832,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.11.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + url: "https://pub.dev" + source: hosted + version: "6.0.5" pub_semver: dependency: transitive description: @@ -864,6 +888,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.8" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: "9566352ab9ba05794ee6c8864f154afba5d36c5637d0e3e32c615ba4ceb92772" + url: "https://pub.dev" + source: hosted + version: "0.2.3" shelf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 14b5b410..c2c4af5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,9 @@ dependencies: printing: ^5.11.0 package_info_plus: ^4.1.0 flutter_typeahead: ^4.8.0 + provider: ^6.0.5 + json_data_explorer: ^0.1.0 + scrollable_positioned_list: ^0.2.3 dev_dependencies: flutter_test: diff --git a/test/widgets/previewer_test.dart b/test/widgets/previewer_test.dart index 3ca571ef..c98a9070 100644 --- a/test/widgets/previewer_test.dart +++ b/test/widgets/previewer_test.dart @@ -13,7 +13,12 @@ void main() { MaterialApp( title: 'Previewer', home: Scaffold( - body: Previewer(type: 'application', subtype: 'pdf', bytes: bytes1), + body: Previewer( + type: 'application', + subtype: 'pdf', + bytes: bytes1, + body: "", + ), ), ), ); @@ -30,7 +35,12 @@ void main() { MaterialApp( title: 'Previewer', home: Scaffold( - body: Previewer(type: 'audio', subtype: 'mpeg', bytes: bytes1), + body: Previewer( + type: 'audio', + subtype: 'mpeg', + bytes: bytes1, + body: "", + ), ), ), ); @@ -43,7 +53,12 @@ void main() { MaterialApp( title: 'Previewer', home: Scaffold( - body: Previewer(type: 'video', subtype: 'H264', bytes: bytes1), + body: Previewer( + type: 'video', + subtype: 'H264', + bytes: bytes1, + body: "", + ), ), ), ); @@ -58,7 +73,12 @@ void main() { MaterialApp( title: 'Previewer', home: Scaffold( - body: Previewer(type: 'model', subtype: 'step+xml', bytes: bytes1), + body: Previewer( + type: 'model', + subtype: 'step+xml', + bytes: bytes1, + body: "", + ), ), ), ); @@ -74,8 +94,12 @@ void main() { MaterialApp( title: 'Previewer', home: Scaffold( - body: - Previewer(type: 'image', subtype: 'jpeg', bytes: kBodyBytesJpeg), + body: Previewer( + type: 'image', + subtype: 'jpeg', + bytes: kBodyBytesJpeg, + body: "", + ), ), ), ); @@ -113,7 +137,11 @@ void main() { title: 'Previewer', home: Scaffold( body: Previewer( - type: 'image', subtype: 'jpeg', bytes: bytesJpegCorrupt), + type: 'image', + subtype: 'jpeg', + bytes: bytesJpegCorrupt, + body: "", + ), ), ), ); @@ -130,7 +158,11 @@ void main() { title: 'Previewer', home: Scaffold( body: Previewer( - type: 'audio', subtype: 'mpeg', bytes: bytesAudioCorrupt), + type: 'audio', + subtype: 'mpeg', + bytes: bytesAudioCorrupt, + body: "", + ), ), ), );