mirror of
https://github.com/foss42/apidash.git
synced 2025-12-08 05:59:15 +08:00
708 lines
21 KiB
Dart
708 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
|
|
import 'json_explorer_store.dart';
|
|
import 'json_explorer_theme.dart';
|
|
|
|
/// Signature for a function that creates a widget based on a
|
|
/// [NodeViewModelState] state.
|
|
typedef NodeBuilder = Widget Function(
|
|
BuildContext context,
|
|
NodeViewModelState node,
|
|
);
|
|
|
|
/// Signature for a function that takes a generic value and converts it to a
|
|
/// string.
|
|
typedef Formatter = String Function(dynamic value);
|
|
|
|
/// Signature for a function that takes a generic value and the current theme
|
|
/// property value style and returns a [StyleBuilder] that allows the style
|
|
/// and interaction to be changed dynamically.
|
|
///
|
|
/// See also:
|
|
/// * [PropertyStyle]
|
|
typedef StyleBuilder = PropertyOverrides Function(
|
|
dynamic value,
|
|
TextStyle style,
|
|
);
|
|
|
|
/// Holds information about a property value style and interaction.
|
|
class PropertyOverrides {
|
|
final TextStyle style;
|
|
final VoidCallback? onTap;
|
|
|
|
const PropertyOverrides({required this.style, this.onTap});
|
|
}
|
|
|
|
/// A widget to display a list of Json nodes.
|
|
///
|
|
/// The [JsonExplorerStore] handles the state of the data structure, so a
|
|
/// [JsonExplorerStore] must be available through a [Provider] for this widget
|
|
/// to fully function, without it, expand and collapse will not work properly.
|
|
///
|
|
/// {@tool snippet}
|
|
/// ```dart
|
|
/// JsonExplorerStore store;
|
|
/// // ...
|
|
/// ChangeNotifierProvider.value(
|
|
/// value: store,
|
|
/// child:
|
|
/// // ...
|
|
/// ```
|
|
/// {@end-tool}
|
|
///
|
|
/// And then a [JsonExplorer] can be built using the store data structure:
|
|
/// {@tool snippet}
|
|
/// ```dart
|
|
/// Widget build(BuildContext context) {
|
|
/// return Scaffold(
|
|
/// appBar: AppBar(
|
|
/// title: Text(widget.title),
|
|
/// ),
|
|
/// body: SafeArea(
|
|
/// minimum: const EdgeInsets.all(16),
|
|
/// child: ChangeNotifierProvider.value(
|
|
/// value: store,
|
|
/// child: Consumer<JsonExplorerStore>(
|
|
/// builder: (context, state, child) => JsonExplorer(
|
|
/// nodes: state.displayNodes,
|
|
/// ),
|
|
/// ),
|
|
/// ),
|
|
/// ),
|
|
/// );
|
|
/// }
|
|
/// ```
|
|
/// {@end-tool}
|
|
class JsonExplorer extends StatelessWidget {
|
|
/// Nodes to be displayed.
|
|
///
|
|
/// See also:
|
|
/// * [JsonExplorerStore]
|
|
final Iterable<NodeViewModelState> nodes;
|
|
|
|
/// Use to control the scroll.
|
|
///
|
|
/// Used to jump or scroll to a particular position.
|
|
final ItemScrollController? itemScrollController;
|
|
|
|
/// Use to listen to scroll position changes.
|
|
final ItemPositionsListener? itemPositionsListener;
|
|
|
|
/// Theme used to render the widgets.
|
|
///
|
|
/// If not set, a default theme will be used.
|
|
final JsonExplorerTheme theme;
|
|
|
|
/// A builder to add a widget as a suffix for root nodes.
|
|
///
|
|
/// This can be used to display useful information such as the number of
|
|
/// children nodes, or to indicate if the node is class or an array
|
|
/// for example.
|
|
final NodeBuilder? rootInformationBuilder;
|
|
|
|
/// Build the expand/collapse icons in root nodes.
|
|
///
|
|
/// If this builder is null, a material [Icons.arrow_right] is displayed for
|
|
/// collapsed nodes and [Icons.arrow_drop_down] for expanded nodes.
|
|
final NodeBuilder? collapsableToggleBuilder;
|
|
|
|
/// A builder to add a trailing widget in each node.
|
|
///
|
|
/// This widget is added to the end of the node on top of the content.
|
|
final NodeBuilder? trailingBuilder;
|
|
|
|
/// Customizes how class/array names are formatted as string.
|
|
///
|
|
/// By default the class and array names are displayed as follows: 'name:'
|
|
final Formatter? rootNameFormatter;
|
|
|
|
/// Customizes how property names are formatted as string.
|
|
///
|
|
/// By default the property names are displayed as follows: 'name:'
|
|
final Formatter? propertyNameFormatter;
|
|
|
|
/// Customizes how property values are formatted as string.
|
|
///
|
|
/// By default the value is converted to a string by calling the .toString()
|
|
/// method.
|
|
final Formatter? valueFormatter;
|
|
|
|
/// Customizes a property style and interaction based on its value.
|
|
///
|
|
/// See also:
|
|
/// * [StyleBuilder]
|
|
final StyleBuilder? valueStyleBuilder;
|
|
|
|
/// Sets the spacing between each list item.
|
|
final double itemSpacing;
|
|
|
|
/// Sets the scroll physics of the list.
|
|
final ScrollPhysics? physics;
|
|
|
|
final double? maxRootNodeWidth;
|
|
|
|
const JsonExplorer({
|
|
Key? key,
|
|
required this.nodes,
|
|
this.itemScrollController,
|
|
this.itemPositionsListener,
|
|
this.rootInformationBuilder,
|
|
this.collapsableToggleBuilder,
|
|
this.trailingBuilder,
|
|
this.rootNameFormatter,
|
|
this.propertyNameFormatter,
|
|
this.valueFormatter,
|
|
this.valueStyleBuilder,
|
|
this.itemSpacing = 2,
|
|
this.physics,
|
|
this.maxRootNodeWidth,
|
|
JsonExplorerTheme? theme,
|
|
}) : theme = theme ?? JsonExplorerTheme.defaultTheme,
|
|
super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ScrollablePositionedList.builder(
|
|
itemCount: nodes.length,
|
|
itemScrollController: itemScrollController,
|
|
itemPositionsListener: itemPositionsListener,
|
|
itemBuilder: (context, index) => AnimatedBuilder(
|
|
animation: nodes.elementAt(index),
|
|
builder: (context, child) => DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: nodes.elementAt(index).isHighlighted
|
|
? theme.highlightColor
|
|
: null,
|
|
),
|
|
child: child,
|
|
),
|
|
child: JsonAttribute(
|
|
node: nodes.elementAt(index),
|
|
rootInformationBuilder: rootInformationBuilder,
|
|
collapsableToggleBuilder: collapsableToggleBuilder,
|
|
trailingBuilder: trailingBuilder,
|
|
rootNameFormatter: rootNameFormatter,
|
|
propertyNameFormatter: propertyNameFormatter,
|
|
valueFormatter: valueFormatter,
|
|
valueStyleBuilder: valueStyleBuilder,
|
|
itemSpacing: itemSpacing,
|
|
theme: theme,
|
|
maxRootNodeWidth: maxRootNodeWidth,
|
|
),
|
|
),
|
|
physics: physics,
|
|
);
|
|
}
|
|
|
|
class JsonAttribute extends StatelessWidget {
|
|
/// Node to be displayed.
|
|
final NodeViewModelState node;
|
|
|
|
/// A builder to add a widget as a suffix for root nodes.
|
|
///
|
|
/// This can be used to display useful information such as the number of
|
|
/// children nodes, or to indicate if the node is class or an array
|
|
/// for example.
|
|
final NodeBuilder? rootInformationBuilder;
|
|
|
|
/// Build the expand/collapse icons in root nodes.
|
|
///
|
|
/// If this builder is null, a material [Icons.arrow_right] is displayed for
|
|
/// collapsed nodes and [Icons.arrow_drop_down] for expanded nodes.
|
|
final NodeBuilder? collapsableToggleBuilder;
|
|
|
|
/// A builder to add a trailing widget in each node.
|
|
///
|
|
/// This widget is added to the end of the node on top of the content.
|
|
final NodeBuilder? trailingBuilder;
|
|
|
|
/// Customizes how class/array names are formatted as string.
|
|
///
|
|
/// By default the class and array names are displayed as follows: 'name:'
|
|
final Formatter? rootNameFormatter;
|
|
|
|
/// Customizes how property names are formatted as string.
|
|
///
|
|
/// By default the property names are displayed as follows: 'name:'
|
|
final Formatter? propertyNameFormatter;
|
|
|
|
/// Customizes how property values are formatted as string.
|
|
///
|
|
/// By default the value is converted to a string by calling the .toString()
|
|
/// method.
|
|
final Formatter? valueFormatter;
|
|
|
|
/// Customizes a property style and interaction based on its value.
|
|
///
|
|
/// See also:
|
|
/// * [StyleBuilder]
|
|
final StyleBuilder? valueStyleBuilder;
|
|
|
|
/// Sets the spacing between each list item.
|
|
final double itemSpacing;
|
|
|
|
/// Theme used to render this widget.
|
|
final JsonExplorerTheme theme;
|
|
|
|
final double? maxRootNodeWidth;
|
|
|
|
const JsonAttribute({
|
|
Key? key,
|
|
required this.node,
|
|
required this.theme,
|
|
this.rootInformationBuilder,
|
|
this.collapsableToggleBuilder,
|
|
this.trailingBuilder,
|
|
this.rootNameFormatter,
|
|
this.propertyNameFormatter,
|
|
this.valueFormatter,
|
|
this.valueStyleBuilder,
|
|
this.itemSpacing = 2,
|
|
this.maxRootNodeWidth,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final searchTerm =
|
|
context.select<JsonExplorerStore, String>((store) => store.searchTerm);
|
|
|
|
final spacing = itemSpacing / 2;
|
|
|
|
final valueStyle = valueStyleBuilder != null
|
|
? valueStyleBuilder!.call(
|
|
node.value,
|
|
theme.valueTextStyle,
|
|
)
|
|
: PropertyOverrides(style: theme.valueTextStyle);
|
|
|
|
final hasInteraction = node.isRoot || valueStyle.onTap != null;
|
|
|
|
return MouseRegion(
|
|
cursor: hasInteraction ? SystemMouseCursors.click : MouseCursor.defer,
|
|
onEnter: (event) {
|
|
node.highlight();
|
|
node.focus();
|
|
},
|
|
onExit: (event) {
|
|
node.highlight(isHighlighted: false);
|
|
node.focus(isFocused: false);
|
|
},
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: hasInteraction
|
|
? () {
|
|
if (valueStyle.onTap != null) {
|
|
valueStyle.onTap!.call();
|
|
} else {
|
|
_onTap(context);
|
|
}
|
|
}
|
|
: null,
|
|
child: AnimatedBuilder(
|
|
animation: node,
|
|
|
|
/// IntrinsicHeight is not the best solution for this, the performance
|
|
/// hit that we measured is ok for now. We will revisit this in the
|
|
/// future if we fill that we need to improve the node rendering
|
|
/// performance
|
|
builder: (context, child) => Stack(
|
|
children: [
|
|
IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: node.isRoot
|
|
? CrossAxisAlignment.center
|
|
: CrossAxisAlignment.start,
|
|
children: [
|
|
_Indentation(
|
|
node: node,
|
|
indentationPadding: theme.indentationPadding,
|
|
propertyPaddingFactor:
|
|
theme.propertyIndentationPaddingFactor,
|
|
lineColor: theme.indentationLineColor,
|
|
),
|
|
if (node.isRoot)
|
|
SizedBox(
|
|
width: 24,
|
|
child: collapsableToggleBuilder?.call(context, node) ??
|
|
_defaultCollapsableToggleBuilder(context, node),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(vertical: spacing),
|
|
child: maxRootNodeWidth != null
|
|
? Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: maxRootNodeWidth!,
|
|
),
|
|
child: _RootNodeWidget(
|
|
node: node,
|
|
rootNameFormatter: rootNameFormatter,
|
|
propertyNameFormatter: propertyNameFormatter,
|
|
searchTerm: searchTerm,
|
|
theme: theme,
|
|
),
|
|
)
|
|
: _RootNodeWidget(
|
|
node: node,
|
|
rootNameFormatter: rootNameFormatter,
|
|
propertyNameFormatter: propertyNameFormatter,
|
|
searchTerm: searchTerm,
|
|
theme: theme,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.symmetric(vertical: spacing),
|
|
child: SizedBox(
|
|
width: 8,
|
|
child: SelectableText(
|
|
':',
|
|
style: theme.rootKeyTextStyle,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
if (node.isRoot)
|
|
rootInformationBuilder?.call(context, node) ??
|
|
const SizedBox()
|
|
else
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.symmetric(vertical: spacing),
|
|
child: _PropertyNodeWidget(
|
|
node: node,
|
|
searchTerm: searchTerm,
|
|
valueFormatter: valueFormatter,
|
|
style: valueStyle.style,
|
|
searchHighlightStyle:
|
|
theme.valueSearchHighlightTextStyle,
|
|
focusedSearchHighlightStyle:
|
|
theme.focusedValueSearchHighlightTextStyle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (trailingBuilder != null)
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: trailingBuilder!.call(context, node),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future _onTap(BuildContext context) async {
|
|
if (node.isRoot) {
|
|
final jsonExplorerStore = Provider.of<JsonExplorerStore>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
if (node.isCollapsed) {
|
|
jsonExplorerStore.expandNode(node);
|
|
} else {
|
|
jsonExplorerStore.collapseNode(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Default value for [collapsableToggleBuilder]
|
|
///
|
|
/// A material [Icons.arrow_right] is displayed for collapsed nodes and
|
|
/// [Icons.arrow_drop_down] for expanded nodes.
|
|
static Widget _defaultCollapsableToggleBuilder(
|
|
BuildContext context,
|
|
NodeViewModelState node,
|
|
) =>
|
|
node.isCollapsed
|
|
? const Icon(
|
|
Icons.arrow_right,
|
|
)
|
|
: const Icon(
|
|
Icons.arrow_drop_down,
|
|
);
|
|
}
|
|
|
|
/// A [Widget] that renders a node that can be a class or a list.
|
|
class _RootNodeWidget extends StatelessWidget {
|
|
final NodeViewModelState node;
|
|
final String searchTerm;
|
|
final Formatter? rootNameFormatter;
|
|
final Formatter? propertyNameFormatter;
|
|
final JsonExplorerTheme theme;
|
|
|
|
const _RootNodeWidget({
|
|
Key? key,
|
|
required this.node,
|
|
required this.searchTerm,
|
|
required this.rootNameFormatter,
|
|
required this.propertyNameFormatter,
|
|
required this.theme,
|
|
}) : super(key: key);
|
|
|
|
String _keyName() {
|
|
if (node.isRoot) {
|
|
return rootNameFormatter?.call(node.key) ?? node.key;
|
|
}
|
|
return propertyNameFormatter?.call(node.key) ?? node.key;
|
|
}
|
|
|
|
/// Gets the index of the focused search match.
|
|
int? _getFocusedSearchMatchIndex(JsonExplorerStore store) {
|
|
if (store.searchResults.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
if (store.focusedSearchResult.node != node) {
|
|
return null;
|
|
}
|
|
|
|
// Assert that it's the key and not the value of the node.
|
|
if (store.focusedSearchResult.matchLocation != SearchMatchLocation.key) {
|
|
return null;
|
|
}
|
|
|
|
return store.focusedSearchResult.matchIndex;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final showHighlightedText = context.select<JsonExplorerStore, bool>(
|
|
(store) => store.searchResults.isNotEmpty,
|
|
);
|
|
|
|
final attributeKeyStyle =
|
|
node.isRoot ? theme.rootKeyTextStyle : theme.propertyKeyTextStyle;
|
|
|
|
final text = _keyName();
|
|
|
|
if (!showHighlightedText) {
|
|
return SelectableText(text, style: attributeKeyStyle);
|
|
}
|
|
|
|
final focusedSearchMatchIndex =
|
|
context.select<JsonExplorerStore, int?>(_getFocusedSearchMatchIndex);
|
|
|
|
return _HighlightedText(
|
|
text: text,
|
|
highlightedText: searchTerm,
|
|
style: attributeKeyStyle,
|
|
primaryMatchStyle: theme.focusedKeySearchNodeHighlightTextStyle,
|
|
secondaryMatchStyle: theme.keySearchHighlightTextStyle,
|
|
focusedSearchMatchIndex: focusedSearchMatchIndex,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A [Widget] that renders a leaf node.
|
|
class _PropertyNodeWidget extends StatelessWidget {
|
|
final NodeViewModelState node;
|
|
final String searchTerm;
|
|
final Formatter? valueFormatter;
|
|
final TextStyle style;
|
|
final TextStyle searchHighlightStyle;
|
|
final TextStyle focusedSearchHighlightStyle;
|
|
|
|
const _PropertyNodeWidget({
|
|
Key? key,
|
|
required this.node,
|
|
required this.searchTerm,
|
|
required this.valueFormatter,
|
|
required this.style,
|
|
required this.searchHighlightStyle,
|
|
required this.focusedSearchHighlightStyle,
|
|
}) : super(key: key);
|
|
|
|
/// Gets the index of the focused search match.
|
|
int? _getFocusedSearchMatchIndex(JsonExplorerStore store) {
|
|
if (store.searchResults.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
if (store.focusedSearchResult.node != node) {
|
|
return null;
|
|
}
|
|
|
|
// Assert that it's the value and not the key of the node.
|
|
if (store.focusedSearchResult.matchLocation != SearchMatchLocation.value) {
|
|
return null;
|
|
}
|
|
|
|
return store.focusedSearchResult.matchIndex;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final showHighlightedText = context.select<JsonExplorerStore, bool>(
|
|
(store) => store.searchResults.isNotEmpty,
|
|
);
|
|
|
|
final text = valueFormatter?.call(node.value) ?? node.value.toString();
|
|
|
|
if (!showHighlightedText) {
|
|
return SelectableText(text, style: style);
|
|
}
|
|
|
|
final focusedSearchMatchIndex =
|
|
context.select<JsonExplorerStore, int?>(_getFocusedSearchMatchIndex);
|
|
|
|
return _HighlightedText(
|
|
text: text,
|
|
highlightedText: searchTerm,
|
|
style: style,
|
|
primaryMatchStyle: focusedSearchHighlightStyle,
|
|
secondaryMatchStyle: searchHighlightStyle,
|
|
focusedSearchMatchIndex: focusedSearchMatchIndex,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Creates the indentation lines and padding of each node depending on its
|
|
/// [node.treeDepth] and whether or not the node is a root node.
|
|
class _Indentation extends StatelessWidget {
|
|
/// Current node view model
|
|
final NodeViewModelState node;
|
|
|
|
/// The padding of each indentation, this change the spacing between each
|
|
/// [node.treeDepth] and the spacing between lines.
|
|
final double indentationPadding;
|
|
|
|
/// Color used to render the indentation lines.
|
|
final Color lineColor;
|
|
|
|
/// A padding factor to be applied on non root nodes, so its properties have
|
|
/// extra padding steps.
|
|
final double propertyPaddingFactor;
|
|
|
|
const _Indentation({
|
|
Key? key,
|
|
required this.node,
|
|
required this.indentationPadding,
|
|
this.lineColor = Colors.grey,
|
|
this.propertyPaddingFactor = 4,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const lineWidth = 1.0;
|
|
return Row(
|
|
children: [
|
|
for (int i = 0; i < node.treeDepth; i++)
|
|
Container(
|
|
margin: EdgeInsets.only(
|
|
right: indentationPadding,
|
|
),
|
|
width: lineWidth,
|
|
color: lineColor,
|
|
),
|
|
if (!node.isRoot)
|
|
SizedBox(
|
|
width: node.treeDepth > 0
|
|
? indentationPadding * propertyPaddingFactor
|
|
: indentationPadding,
|
|
),
|
|
if (node.isRoot && !node.isCollapsed) ...[
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: FractionallySizedBox(
|
|
heightFactor: 0.52,
|
|
child: Container(
|
|
width: 1,
|
|
color: lineColor,
|
|
),
|
|
),
|
|
),
|
|
Container(
|
|
height: lineWidth,
|
|
width: (indentationPadding / 2) - lineWidth,
|
|
color: lineColor,
|
|
),
|
|
],
|
|
if (node.isRoot && node.isCollapsed)
|
|
SizedBox(
|
|
width: indentationPadding / 2,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Highlights found occurrences of [highlightedText] with [highlightedStyle]
|
|
/// in [text].
|
|
class _HighlightedText extends StatelessWidget {
|
|
final String text;
|
|
final String highlightedText;
|
|
|
|
// The default style when the text or part of it is not highlighted.
|
|
final TextStyle style;
|
|
|
|
// The style of the focused search match.
|
|
final TextStyle primaryMatchStyle;
|
|
|
|
// The style of the search match that is not focused.
|
|
final TextStyle secondaryMatchStyle;
|
|
|
|
// The index of the focused search match.
|
|
final int? focusedSearchMatchIndex;
|
|
|
|
const _HighlightedText({
|
|
Key? key,
|
|
required this.text,
|
|
required this.highlightedText,
|
|
required this.style,
|
|
required this.primaryMatchStyle,
|
|
required this.secondaryMatchStyle,
|
|
required this.focusedSearchMatchIndex,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final lowerCaseText = text.toLowerCase();
|
|
final lowerCaseQuery = highlightedText.toLowerCase();
|
|
|
|
if (highlightedText.isEmpty || !lowerCaseText.contains(lowerCaseQuery)) {
|
|
return SelectableText(text, style: style);
|
|
}
|
|
|
|
final spans = <TextSpan>[];
|
|
var start = 0;
|
|
|
|
while (true) {
|
|
var index = lowerCaseText.indexOf(lowerCaseQuery, start);
|
|
index = index >= 0 ? index : text.length;
|
|
|
|
if (start != index) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: text.substring(start, index),
|
|
style: style,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (index >= text.length) {
|
|
break;
|
|
}
|
|
|
|
spans.add(
|
|
TextSpan(
|
|
text: text.substring(index, index + highlightedText.length),
|
|
style: index == focusedSearchMatchIndex
|
|
? primaryMatchStyle
|
|
: secondaryMatchStyle,
|
|
),
|
|
);
|
|
start = index + highlightedText.length;
|
|
}
|
|
|
|
return SelectableText.rich(
|
|
TextSpan(
|
|
children: spans,
|
|
),
|
|
);
|
|
}
|
|
}
|