feat: Add JSON Viewer & search

This commit is contained in:
Ashita Prasad
2023-10-15 14:36:42 +05:30
parent 4c5ecd59b0
commit 7d81724936
7 changed files with 314 additions and 7 deletions

View File

@ -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<String, Map<String, List<ResponseBodyView>>>
kResponseBodyViewOptions = {
kTypeApplication: {
kSubTypeDefaultViewOptions: kNoRawBodyViewOptions,
kSubTypeJson: kCodeRawBodyViewOptions,
kSubTypeJson: kPreviewRawBodyViewOptions,
kSubTypeOctetStream: kNoBodyViewOptions,
kSubTypePdf: kPreviewBodyViewOptions,
kSubTypeSql: kCodeRawBodyViewOptions,

View File

@ -0,0 +1,254 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:json_data_explorer/json_data_explorer.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class JsonPreviewer extends StatefulWidget {
const JsonPreviewer({
super.key,
required this.code,
});
final String 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(jsonDecode(widget.code), areAllCollapsed: true);
store.expandAll();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: store,
child: Consumer<DataExplorerStore>(
builder: (context, state, child) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
controller: searchController,
/// Delegates the search to [DataExplorerStore] when
/// the text field changes.
onChanged: (term) => state.search(term),
decoration: const InputDecoration(
hintText: 'Search',
),
),
),
const SizedBox(
width: 8,
),
if (state.searchResults.isNotEmpty) Text(_searchFocusText()),
if (state.searchResults.isNotEmpty)
IconButton(
onPressed: () {
store.focusPreviousSearchResult();
_scrollToSearchMatch();
},
icon: const Icon(Icons.arrow_drop_up),
),
if (state.searchResults.isNotEmpty)
IconButton(
onPressed: () {
store.focusNextSearchResult();
_scrollToSearchMatch();
},
icon: const Icon(Icons.arrow_drop_down),
),
],
),
const SizedBox(
height: 16.0,
),
Row(
children: [
TextButton(
onPressed: state.areAllExpanded() ? null : state.expandAll,
child: const Text('Expand All'),
),
const SizedBox(
width: 8.0,
),
TextButton(
onPressed: state.areAllCollapsed() ? null : state.collapseAll,
child: const Text('Collapse All'),
),
],
),
const SizedBox(
height: 16.0,
),
Expanded(
child: JsonDataExplorer(
nodes: state.displayNodes,
itemScrollController: itemScrollController,
itemSpacing: 4,
/// Builds a widget after each root node displaying the
/// number of children nodes that it has. Displays `{x}`
/// if it is a class or `[x]` in case of arrays.
rootInformationBuilder: (context, node) => DecoratedBox(
decoration: const BoxDecoration(
color: Color(0x80E1E1E1),
borderRadius: BorderRadius.all(Radius.circular(2)),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: Text(
node.isClass
? '{${node.childrenCount}}'
: '[${node.childrenCount}]',
style: TextStyle(
fontSize: 12,
color: const Color(0xFF6F6F6F),
),
),
),
),
/// Build an animated collapse/expand indicator. Implicitly
/// animates the indicator when
/// [NodeViewModelState.isCollapsed] changes.
collapsableToggleBuilder: (context, node) => AnimatedRotation(
turns: node.isCollapsed ? -0.25 : 0,
duration: const Duration(milliseconds: 300),
child: const Icon(Icons.arrow_drop_down),
),
/// Builds a trailing widget that copies the node key: value
///
/// Uses [NodeViewModelState.isFocused] to display the
/// widget only in focused widgets.
trailingBuilder: (context, node) => node.isFocused
? IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints(maxHeight: 18),
icon: const Icon(
Icons.copy,
size: 18,
),
onPressed: () => _printNode(node),
)
: const SizedBox(),
/// Creates a custom format for classes and array names.
rootNameFormatter: (dynamic name) => '$name',
/// Dynamically changes the property value style and
/// interaction when an URL is detected.
valueStyleBuilder: (dynamic value, style) {
final isUrl = _valueIsUrl(value);
return PropertyOverrides(
style: isUrl
? style.copyWith(
decoration: TextDecoration.underline,
)
: style,
//onTap: isUrl ? () => _launchUrl(value as String) : null,
);
},
/// Theme definitions of the json data explorer
theme: DataExplorerTheme(
rootKeyTextStyle: TextStyle(
color: Colors.black,
fontWeight: FontWeight.bold,
fontSize: 16,
),
propertyKeyTextStyle: TextStyle(
color: Colors.black.withOpacity(0.7),
fontWeight: FontWeight.bold,
fontSize: 16,
),
keySearchHighlightTextStyle: TextStyle(
color: Colors.black,
backgroundColor: const Color(0xFFFFEDAD),
fontWeight: FontWeight.bold,
fontSize: 16,
),
focusedKeySearchHighlightTextStyle: TextStyle(
color: Colors.black,
backgroundColor: const Color(0xFFF29D0B),
fontWeight: FontWeight.bold,
fontSize: 16,
),
valueTextStyle: TextStyle(
color: const Color(0xFFCA442C),
fontSize: 16,
),
valueSearchHighlightTextStyle: TextStyle(
color: const Color(0xFFCA442C),
backgroundColor: const Color(0xFFFFEDAD),
fontWeight: FontWeight.bold,
fontSize: 16,
),
focusedValueSearchHighlightTextStyle: TextStyle(
color: Colors.black,
backgroundColor: const Color(0xFFF29D0B),
fontWeight: FontWeight.bold,
fontSize: 16,
),
indentationLineColor: const Color(0xFFE1E1E1),
highlightColor: const Color(0xFFF1F1F1),
),
),
),
],
),
),
);
}
String _searchFocusText() =>
'${store.focusedSearchResultIndex + 1} of ${store.searchResults.length}';
void _printNode(NodeViewModelState node) {
if (node.isRoot) {
final value = node.isClass ? 'class' : 'array';
debugPrint('${node.key}: $value');
return;
}
debugPrint('${node.key}: ${node.value}');
}
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;
}
@override
void dispose() {
searchController.dispose();
super.dispose();
}
}

View File

@ -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<Previewer> {
@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,

View File

@ -453,11 +453,17 @@ class _BodySuccessState extends State<BodySuccess> {
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),
),
),
),
),

View File

@ -18,3 +18,4 @@ export 'snackbars.dart';
export 'markdown.dart';
export 'uint8_audio_player.dart';
export 'tabs.dart';
export 'json_previewer.dart';

View File

@ -464,6 +464,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:
@ -600,6 +608,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:
@ -752,6 +768,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:
@ -800,6 +824,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:

View File

@ -41,6 +41,9 @@ dependencies:
json_annotation: ^4.8.1
printing: ^5.11.0
package_info_plus: ^4.1.0
provider: ^6.0.5
json_data_explorer: ^0.1.0
scrollable_positioned_list: ^0.2.3
dev_dependencies:
flutter_test: