You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
636 lines
25 KiB
636 lines
25 KiB
import 'package:collection/collection.dart';
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import './flutter_html.dart';
|
|
import './src/utils.dart';
|
|
|
|
typedef CustomRenderMatcher = bool Function(RenderContext context);
|
|
|
|
CustomRenderMatcher tagMatcher(String tag) => (context) {
|
|
return context.tree.element?.localName == tag;
|
|
};
|
|
|
|
CustomRenderMatcher blockElementMatcher() => (context) {
|
|
return context.tree.style.display == Display.BLOCK &&
|
|
(context.tree.children.isNotEmpty ||
|
|
context.tree.element?.localName == "hr");
|
|
};
|
|
|
|
CustomRenderMatcher listElementMatcher() => (context) {
|
|
return context.tree.style.display == Display.LIST_ITEM;
|
|
};
|
|
|
|
CustomRenderMatcher replacedElementMatcher() => (context) {
|
|
return context.tree is ReplacedElement;
|
|
};
|
|
|
|
CustomRenderMatcher dataUriMatcher(
|
|
{String? encoding = 'base64', String? mime}) =>
|
|
(context) {
|
|
if (context.tree.element?.attributes == null ||
|
|
_src(context.tree.element!.attributes.cast()) == null) return false;
|
|
final dataUri = _dataUriFormat
|
|
.firstMatch(_src(context.tree.element!.attributes.cast())!);
|
|
return dataUri != null &&
|
|
dataUri.namedGroup('mime') != "image/svg+xml" &&
|
|
(mime == null || dataUri.namedGroup('mime') == mime) &&
|
|
(encoding == null || dataUri.namedGroup('encoding') == ';$encoding');
|
|
};
|
|
|
|
CustomRenderMatcher networkSourceMatcher({
|
|
List<String> schemas: const ["https", "http"],
|
|
List<String>? domains,
|
|
String? extension,
|
|
}) =>
|
|
(context) {
|
|
if (context.tree.element?.attributes.cast() == null ||
|
|
_src(context.tree.element!.attributes.cast()) == null) return false;
|
|
try {
|
|
final src = Uri.parse(_src(context.tree.element!.attributes.cast())!);
|
|
return schemas.contains(src.scheme) &&
|
|
(domains == null || domains.contains(src.host)) &&
|
|
(extension == null || src.path.endsWith(".$extension"));
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
CustomRenderMatcher assetUriMatcher() => (context) =>
|
|
context.tree.element?.attributes.cast() != null &&
|
|
_src(context.tree.element!.attributes.cast()) != null &&
|
|
_src(context.tree.element!.attributes.cast())!.startsWith("asset:") &&
|
|
!_src(context.tree.element!.attributes.cast())!.endsWith(".svg");
|
|
|
|
CustomRenderMatcher textContentElementMatcher() => (context) {
|
|
return context.tree is TextContentElement;
|
|
};
|
|
|
|
CustomRenderMatcher interactableElementMatcher() => (context) {
|
|
return context.tree is InteractableElement;
|
|
};
|
|
|
|
CustomRenderMatcher layoutElementMatcher() => (context) {
|
|
return context.tree is LayoutElement;
|
|
};
|
|
|
|
CustomRenderMatcher verticalAlignMatcher() => (context) {
|
|
return context.tree.style.verticalAlign != null &&
|
|
context.tree.style.verticalAlign != VerticalAlign.BASELINE;
|
|
};
|
|
|
|
CustomRenderMatcher fallbackMatcher() => (context) {
|
|
return true;
|
|
};
|
|
|
|
class CustomRender {
|
|
final InlineSpan Function(RenderContext, List<InlineSpan> Function())?
|
|
inlineSpan;
|
|
final Widget Function(RenderContext, List<InlineSpan> Function())? widget;
|
|
|
|
CustomRender.inlineSpan({
|
|
required this.inlineSpan,
|
|
}) : widget = null;
|
|
|
|
CustomRender.widget({
|
|
required this.widget,
|
|
}) : inlineSpan = null;
|
|
}
|
|
|
|
class SelectableCustomRender extends CustomRender {
|
|
final TextSpan Function(RenderContext, List<TextSpan> Function()) textSpan;
|
|
|
|
SelectableCustomRender.fromTextSpan({
|
|
required this.textSpan,
|
|
}) : super.inlineSpan(inlineSpan: null);
|
|
}
|
|
|
|
CustomRender blockElementRender({Style? style, List<InlineSpan>? children}) =>
|
|
CustomRender.inlineSpan(inlineSpan: (context, buildChildren) {
|
|
if (context.parser.selectable) {
|
|
return TextSpan(
|
|
style: context.style.generateTextStyle(),
|
|
children: (children as List<TextSpan>?) ??
|
|
context.tree.children
|
|
.expandIndexed((i, childTree) => [
|
|
if (childTree.style.display == Display.BLOCK &&
|
|
i > 0 &&
|
|
context.tree.children[i - 1] is ReplacedElement)
|
|
TextSpan(text: "\n"),
|
|
context.parser.parseTree(context, childTree),
|
|
if (i != context.tree.children.length - 1 &&
|
|
childTree.style.display == Display.BLOCK &&
|
|
childTree.element?.localName != "html" &&
|
|
childTree.element?.localName != "body")
|
|
TextSpan(text: "\n"),
|
|
])
|
|
.toList(),
|
|
);
|
|
}
|
|
return WidgetSpan(
|
|
child: ContainerSpan(
|
|
key: context.key,
|
|
newContext: context,
|
|
style: style ?? context.tree.style,
|
|
shrinkWrap: context.parser.shrinkWrap,
|
|
children: children ??
|
|
context.tree.children
|
|
.expandIndexed((i, childTree) => [
|
|
if (context.parser.shrinkWrap &&
|
|
childTree.style.display == Display.BLOCK &&
|
|
i > 0 &&
|
|
context.tree.children[i - 1] is ReplacedElement)
|
|
TextSpan(text: "\n"),
|
|
context.parser.parseTree(context, childTree),
|
|
if (i != context.tree.children.length - 1 &&
|
|
childTree.style.display == Display.BLOCK &&
|
|
childTree.element?.localName != "html" &&
|
|
childTree.element?.localName != "body")
|
|
TextSpan(text: "\n"),
|
|
])
|
|
.toList(),
|
|
));
|
|
});
|
|
|
|
CustomRender listElementRender(
|
|
{Style? style, Widget? child, List<InlineSpan>? children}) =>
|
|
CustomRender.inlineSpan(
|
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
|
child: ContainerSpan(
|
|
key: context.key,
|
|
newContext: context,
|
|
style: style ?? context.tree.style,
|
|
shrinkWrap: context.parser.shrinkWrap,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
textDirection:
|
|
style?.direction ?? context.tree.style.direction,
|
|
children: [
|
|
(style?.listStylePosition ??
|
|
context.tree.style.listStylePosition) ==
|
|
ListStylePosition.OUTSIDE
|
|
? Padding(
|
|
padding: style?.padding?.nonNegative ??
|
|
context.tree.style.padding?.nonNegative ??
|
|
EdgeInsets.only(
|
|
left: (style?.direction ??
|
|
context.tree.style.direction) !=
|
|
TextDirection.rtl
|
|
? 10.0
|
|
: 0.0,
|
|
right: (style?.direction ??
|
|
context.tree.style.direction) ==
|
|
TextDirection.rtl
|
|
? 10.0
|
|
: 0.0),
|
|
child: style?.markerContent ??
|
|
context.style.markerContent)
|
|
: Container(height: 0, width: 0),
|
|
Text("\u0020",
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(fontWeight: FontWeight.w400)),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: (style?.listStylePosition ??
|
|
context.tree.style.listStylePosition) ==
|
|
ListStylePosition.INSIDE
|
|
? EdgeInsets.only(
|
|
left: (style?.direction ??
|
|
context.tree.style.direction) !=
|
|
TextDirection.rtl
|
|
? 10.0
|
|
: 0.0,
|
|
right: (style?.direction ??
|
|
context.tree.style.direction) ==
|
|
TextDirection.rtl
|
|
? 10.0
|
|
: 0.0)
|
|
: EdgeInsets.zero,
|
|
child: StyledText(
|
|
textSpan: TextSpan(
|
|
children: _getListElementChildren(
|
|
style?.listStylePosition ??
|
|
context.tree.style.listStylePosition,
|
|
buildChildren)
|
|
..insertAll(
|
|
0,
|
|
context.tree.style.listStylePosition ==
|
|
ListStylePosition.INSIDE
|
|
? [
|
|
WidgetSpan(
|
|
alignment:
|
|
PlaceholderAlignment
|
|
.middle,
|
|
child: style?.markerContent ??
|
|
context.style
|
|
.markerContent ??
|
|
Container(
|
|
height: 0, width: 0))
|
|
]
|
|
: []),
|
|
style: style?.generateTextStyle() ??
|
|
context.style.generateTextStyle(),
|
|
),
|
|
style: style ?? context.style,
|
|
renderContext: context,
|
|
)))
|
|
],
|
|
),
|
|
),
|
|
));
|
|
|
|
CustomRender replacedElementRender(
|
|
{PlaceholderAlignment? alignment,
|
|
TextBaseline? baseline,
|
|
Widget? child}) =>
|
|
CustomRender.inlineSpan(
|
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
|
alignment:
|
|
alignment ?? (context.tree as ReplacedElement).alignment,
|
|
baseline: baseline ?? TextBaseline.alphabetic,
|
|
child:
|
|
child ?? (context.tree as ReplacedElement).toWidget(context)!,
|
|
));
|
|
|
|
CustomRender textContentElementRender({String? text}) =>
|
|
CustomRender.inlineSpan(
|
|
inlineSpan: (context, buildChildren) => TextSpan(
|
|
style: context.style.generateTextStyle(),
|
|
text: (text ?? (context.tree as TextContentElement).text)
|
|
.transformed(context.tree.style.textTransform),
|
|
));
|
|
|
|
CustomRender base64ImageRender() =>
|
|
CustomRender.widget(widget: (context, buildChildren) {
|
|
final decodedImage = base64.decode(
|
|
_src(context.tree.element!.attributes.cast())!
|
|
.split("base64,")[1]
|
|
.trim());
|
|
precacheImage(
|
|
MemoryImage(decodedImage),
|
|
context.buildContext,
|
|
onError: (exception, StackTrace? stackTrace) {
|
|
context.parser.onImageError?.call(exception, stackTrace);
|
|
},
|
|
);
|
|
final widget = Image.memory(
|
|
decodedImage,
|
|
frameBuilder: (ctx, child, frame, _) {
|
|
if (frame == null) {
|
|
return Text(_alt(context.tree.element!.attributes.cast()) ?? "",
|
|
style: context.style.generateTextStyle());
|
|
}
|
|
return child;
|
|
},
|
|
);
|
|
return Builder(
|
|
key: context.key,
|
|
builder: (buildContext) {
|
|
return GestureDetector(
|
|
child: widget,
|
|
onTap: () {
|
|
if (MultipleTapGestureDetector.of(buildContext) != null) {
|
|
MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
|
|
}
|
|
context.parser.onImageTap?.call(
|
|
_src(context.tree.element!.attributes.cast())!
|
|
.split("base64,")[1]
|
|
.trim(),
|
|
context,
|
|
context.tree.element!.attributes.cast(),
|
|
context.tree.element);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
CustomRender assetImageRender({
|
|
double? width,
|
|
double? height,
|
|
}) =>
|
|
CustomRender.widget(widget: (context, buildChildren) {
|
|
final assetPath = _src(context.tree.element!.attributes.cast())!
|
|
.replaceFirst('asset:', '');
|
|
final widget = Image.asset(
|
|
assetPath,
|
|
width: width ?? _width(context.tree.element!.attributes.cast()),
|
|
height: height ?? _height(context.tree.element!.attributes.cast()),
|
|
frameBuilder: (ctx, child, frame, _) {
|
|
if (frame == null) {
|
|
return Text(_alt(context.tree.element!.attributes.cast()) ?? "",
|
|
style: context.style.generateTextStyle());
|
|
}
|
|
return child;
|
|
},
|
|
);
|
|
return Builder(
|
|
key: context.key,
|
|
builder: (buildContext) {
|
|
return GestureDetector(
|
|
child: widget,
|
|
onTap: () {
|
|
if (MultipleTapGestureDetector.of(buildContext) != null) {
|
|
MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
|
|
}
|
|
context.parser.onImageTap?.call(
|
|
assetPath,
|
|
context,
|
|
context.tree.element!.attributes.cast(),
|
|
context.tree.element);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
CustomRender networkImageRender({
|
|
Map<String, String>? headers,
|
|
String Function(String?)? mapUrl,
|
|
double? width,
|
|
double? height,
|
|
Widget Function(String?)? altWidget,
|
|
Widget Function()? loadingWidget,
|
|
}) =>
|
|
CustomRender.widget(widget: (context, buildChildren) {
|
|
final src = mapUrl?.call(_src(context.tree.element!.attributes.cast())) ??
|
|
_src(context.tree.element!.attributes.cast())!;
|
|
Completer<Size> completer = Completer();
|
|
if (context.parser.cachedImageSizes[src] != null) {
|
|
completer.complete(context.parser.cachedImageSizes[src]);
|
|
} else {
|
|
Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) {
|
|
if (frame == null) {
|
|
if (!completer.isCompleted) {
|
|
completer.completeError("error");
|
|
}
|
|
return child;
|
|
} else {
|
|
return child;
|
|
}
|
|
});
|
|
|
|
ImageStreamListener? listener;
|
|
listener =
|
|
ImageStreamListener((ImageInfo imageInfo, bool synchronousCall) {
|
|
var myImage = imageInfo.image;
|
|
Size size = Size(myImage.width.toDouble(), myImage.height.toDouble());
|
|
if (!completer.isCompleted) {
|
|
context.parser.cachedImageSizes[src] = size;
|
|
completer.complete(size);
|
|
image.image.resolve(ImageConfiguration()).removeListener(listener!);
|
|
}
|
|
}, onError: (object, stacktrace) {
|
|
if (!completer.isCompleted) {
|
|
completer.completeError(object);
|
|
image.image.resolve(ImageConfiguration()).removeListener(listener!);
|
|
}
|
|
});
|
|
|
|
image.image.resolve(ImageConfiguration()).addListener(listener);
|
|
}
|
|
final attributes =
|
|
context.tree.element!.attributes.cast<String, String>();
|
|
final widget = FutureBuilder<Size>(
|
|
future: completer.future,
|
|
initialData: context.parser.cachedImageSizes[src],
|
|
builder: (BuildContext buildContext, AsyncSnapshot<Size> snapshot) {
|
|
if (snapshot.hasData) {
|
|
return Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: width ?? _width(attributes) ?? snapshot.data!.width,
|
|
maxHeight:
|
|
(width ?? _width(attributes) ?? snapshot.data!.width) /
|
|
_aspectRatio(attributes, snapshot)),
|
|
child: AspectRatio(
|
|
aspectRatio: _aspectRatio(attributes, snapshot),
|
|
child: Image.network(
|
|
src,
|
|
headers: headers,
|
|
width: width ?? _width(attributes) ?? snapshot.data!.width,
|
|
height: height ?? _height(attributes),
|
|
frameBuilder: (ctx, child, frame, _) {
|
|
if (frame == null) {
|
|
return altWidget?.call(_alt(attributes)) ??
|
|
Text(_alt(attributes) ?? "",
|
|
style: context.style.generateTextStyle());
|
|
}
|
|
return child;
|
|
},
|
|
),
|
|
),
|
|
);
|
|
} else if (snapshot.hasError) {
|
|
return altWidget
|
|
?.call(_alt(context.tree.element!.attributes.cast())) ??
|
|
Text(_alt(context.tree.element!.attributes.cast()) ?? "",
|
|
style: context.style.generateTextStyle());
|
|
} else {
|
|
return loadingWidget?.call() ?? const CircularProgressIndicator();
|
|
}
|
|
},
|
|
);
|
|
return Builder(
|
|
key: context.key,
|
|
builder: (buildContext) {
|
|
return GestureDetector(
|
|
child: widget,
|
|
onTap: () {
|
|
if (MultipleTapGestureDetector.of(buildContext) != null) {
|
|
MultipleTapGestureDetector.of(buildContext)!.onTap?.call();
|
|
}
|
|
context.parser.onImageTap?.call(
|
|
src,
|
|
context,
|
|
context.tree.element!.attributes.cast(),
|
|
context.tree.element);
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
CustomRender interactableElementRender({List<InlineSpan>? children}) =>
|
|
CustomRender.inlineSpan(
|
|
inlineSpan: (context, buildChildren) => TextSpan(
|
|
children: children ??
|
|
(context.tree as InteractableElement)
|
|
.children
|
|
.map((tree) => context.parser.parseTree(context, tree))
|
|
.map((childSpan) {
|
|
return _getInteractableChildren(
|
|
context,
|
|
context.tree as InteractableElement,
|
|
childSpan,
|
|
context.style
|
|
.generateTextStyle()
|
|
.merge(childSpan.style));
|
|
}).toList(),
|
|
));
|
|
|
|
CustomRender layoutElementRender({Widget? child}) => CustomRender.inlineSpan(
|
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
|
child: child ?? (context.tree as LayoutElement).toWidget(context)!,
|
|
));
|
|
|
|
CustomRender verticalAlignRender(
|
|
{double? verticalOffset, Style? style, List<InlineSpan>? children}) =>
|
|
CustomRender.inlineSpan(
|
|
inlineSpan: (context, buildChildren) => WidgetSpan(
|
|
child: Transform.translate(
|
|
key: context.key,
|
|
offset: Offset(
|
|
0, verticalOffset ?? _getVerticalOffset(context.tree)),
|
|
child: StyledText(
|
|
textSpan: TextSpan(
|
|
style: style?.generateTextStyle() ??
|
|
context.style.generateTextStyle(),
|
|
children: children ?? buildChildren.call(),
|
|
),
|
|
style: context.style,
|
|
renderContext: context,
|
|
),
|
|
),
|
|
));
|
|
|
|
CustomRender fallbackRender({Style? style, List<InlineSpan>? children}) =>
|
|
CustomRender.inlineSpan(
|
|
inlineSpan: (context, buildChildren) => TextSpan(
|
|
style: style?.generateTextStyle() ??
|
|
context.style.generateTextStyle(),
|
|
children: context.tree.children
|
|
.expand((tree) => [
|
|
context.parser.parseTree(context, tree),
|
|
if (tree.style.display == Display.BLOCK &&
|
|
tree.element?.parent?.localName != "th" &&
|
|
tree.element?.parent?.localName != "td" &&
|
|
tree.element?.localName != "html" &&
|
|
tree.element?.localName != "body")
|
|
TextSpan(text: "\n"),
|
|
])
|
|
.toList(),
|
|
));
|
|
|
|
final Map<CustomRenderMatcher, CustomRender> defaultRenders = {
|
|
blockElementMatcher(): blockElementRender(),
|
|
listElementMatcher(): listElementRender(),
|
|
textContentElementMatcher(): textContentElementRender(),
|
|
dataUriMatcher(): base64ImageRender(),
|
|
assetUriMatcher(): assetImageRender(),
|
|
networkSourceMatcher(): networkImageRender(),
|
|
replacedElementMatcher(): replacedElementRender(),
|
|
interactableElementMatcher(): interactableElementRender(),
|
|
layoutElementMatcher(): layoutElementRender(),
|
|
verticalAlignMatcher(): verticalAlignRender(),
|
|
fallbackMatcher(): fallbackRender(),
|
|
};
|
|
|
|
List<InlineSpan> _getListElementChildren(
|
|
ListStylePosition? position, Function() buildChildren) {
|
|
List<InlineSpan> children = buildChildren.call();
|
|
if (position == ListStylePosition.INSIDE) {
|
|
final tabSpan = WidgetSpan(
|
|
child: Text("\t",
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(fontWeight: FontWeight.w400)),
|
|
);
|
|
children.insert(0, tabSpan);
|
|
}
|
|
return children;
|
|
}
|
|
|
|
InlineSpan _getInteractableChildren(RenderContext context,
|
|
InteractableElement tree, InlineSpan childSpan, TextStyle childStyle) {
|
|
if (childSpan is TextSpan) {
|
|
return TextSpan(
|
|
text: childSpan.text,
|
|
children: childSpan.children
|
|
?.map((e) => _getInteractableChildren(
|
|
context, tree, e, childStyle.merge(childSpan.style)))
|
|
.toList(),
|
|
style: context.style.generateTextStyle().merge(childSpan.style == null
|
|
? childStyle
|
|
: childStyle.merge(childSpan.style)),
|
|
semanticsLabel: childSpan.semanticsLabel,
|
|
recognizer: TapGestureRecognizer()
|
|
..onTap = context.parser.internalOnAnchorTap != null
|
|
? () => context.parser.internalOnAnchorTap!(
|
|
tree.href, context, tree.attributes, tree.element)
|
|
: null,
|
|
);
|
|
} else {
|
|
return WidgetSpan(
|
|
child: MultipleTapGestureDetector(
|
|
onTap: context.parser.internalOnAnchorTap != null
|
|
? () => context.parser.internalOnAnchorTap!(
|
|
tree.href, context, tree.attributes, tree.element)
|
|
: null,
|
|
child: GestureDetector(
|
|
key: context.key,
|
|
onTap: context.parser.internalOnAnchorTap != null
|
|
? () => context.parser.internalOnAnchorTap!(
|
|
tree.href, context, tree.attributes, tree.element)
|
|
: null,
|
|
child: (childSpan as WidgetSpan).child,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
final _dataUriFormat = RegExp(
|
|
"^(?<scheme>data):(?<mime>image\/[\\w\+\-\.]+)(?<encoding>;base64)?\,(?<data>.*)");
|
|
|
|
double _getVerticalOffset(StyledElement tree) {
|
|
switch (tree.style.verticalAlign) {
|
|
case VerticalAlign.SUB:
|
|
return tree.style.fontSize!.size! / 2.5;
|
|
case VerticalAlign.SUPER:
|
|
return tree.style.fontSize!.size! / -2.5;
|
|
default:
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
String? _src(Map<String, String> attributes) {
|
|
return attributes["src"];
|
|
}
|
|
|
|
String? _alt(Map<String, String> attributes) {
|
|
return attributes["alt"];
|
|
}
|
|
|
|
double? _height(Map<String, String> attributes) {
|
|
final heightString = attributes["height"];
|
|
return heightString == null
|
|
? heightString as double?
|
|
: double.tryParse(heightString);
|
|
}
|
|
|
|
double? _width(Map<String, String> attributes) {
|
|
final widthString = attributes["width"];
|
|
return widthString == null
|
|
? widthString as double?
|
|
: double.tryParse(widthString);
|
|
}
|
|
|
|
double _aspectRatio(
|
|
Map<String, String> attributes, AsyncSnapshot<Size> calculated) {
|
|
final heightString = attributes["height"];
|
|
final widthString = attributes["width"];
|
|
if (heightString != null && widthString != null) {
|
|
final height = double.tryParse(heightString);
|
|
final width = double.tryParse(widthString);
|
|
return height == null || width == null
|
|
? calculated.data!.aspectRatio
|
|
: width / height;
|
|
}
|
|
return calculated.data!.aspectRatio;
|
|
}
|
|
|
|
extension ClampedEdgeInsets on EdgeInsetsGeometry {
|
|
EdgeInsetsGeometry get nonNegative =>
|
|
this.clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity));
|
|
}
|