|
@@ -0,0 +1,473 @@
|
|
|
+import 'dart:math' as math;
|
|
|
+import 'package:fis_theme/theme.dart';
|
|
|
+import 'package:fis_ui/index.dart';
|
|
|
+import 'package:flutter/material.dart';
|
|
|
+
|
|
|
+typedef FFormItemValidateStepFn = bool Function();
|
|
|
+
|
|
|
+/// 表单项验证步骤
|
|
|
+class FFormItemValidateStep {
|
|
|
+ FFormItemValidateStep({
|
|
|
+ required this.validateFn,
|
|
|
+ required this.tips,
|
|
|
+ });
|
|
|
+ FFormItemValidateStepFn validateFn;
|
|
|
+ String tips;
|
|
|
+}
|
|
|
+
|
|
|
+/// FFormItemValidator控制器
|
|
|
+class FFormItemValidatorController extends ChangeNotifier {
|
|
|
+ bool _isDisposed = false;
|
|
|
+ bool _isShowing = false;
|
|
|
+ bool _isValidating = false;
|
|
|
+
|
|
|
+ /// 是否在显示
|
|
|
+ bool get isShowing => _isShowing;
|
|
|
+
|
|
|
+ /// 是否在验证中
|
|
|
+ bool get isValidating => _isValidating;
|
|
|
+
|
|
|
+ /// 需要显示自定义错误提示
|
|
|
+ bool get needShowCustom => customErrorText.isNotEmpty;
|
|
|
+
|
|
|
+ /// 自定义错误提示
|
|
|
+ String customErrorText = '';
|
|
|
+
|
|
|
+ @override
|
|
|
+ void addListener(VoidCallback listener) {
|
|
|
+ if (_isDisposed) return;
|
|
|
+ super.addListener(listener);
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void removeListener(VoidCallback listener) {
|
|
|
+ if (_isDisposed) return;
|
|
|
+ super.removeListener(listener);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 重置验证
|
|
|
+ void resetValidate() {
|
|
|
+ if (_isDisposed) return;
|
|
|
+ _isValidating = false;
|
|
|
+ _isShowing = true;
|
|
|
+ notifyListeners();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 开始验证
|
|
|
+ void validate() {
|
|
|
+ if (_isDisposed) return;
|
|
|
+ _isShowing = true;
|
|
|
+ _isValidating = true;
|
|
|
+ notifyListeners();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 隐藏
|
|
|
+ void hide() {
|
|
|
+ if (_isDisposed) return;
|
|
|
+ _isShowing = false;
|
|
|
+ _isValidating = false;
|
|
|
+ notifyListeners();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 展示手动控制的错误提示
|
|
|
+ void showCustomError(String text) {
|
|
|
+ if (_isDisposed) return;
|
|
|
+ customErrorText = text;
|
|
|
+ notifyListeners();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 移除手动控制的错误提示
|
|
|
+ void removeCustomError() {
|
|
|
+ if (_isDisposed) return;
|
|
|
+ customErrorText = '';
|
|
|
+ notifyListeners();
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void dispose() {
|
|
|
+ _isDisposed = true;
|
|
|
+ super.dispose();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// 表单项验证组件
|
|
|
+class FFormItemValidator extends StatefulWidget implements FWidget {
|
|
|
+ const FFormItemValidator({
|
|
|
+ Key? key,
|
|
|
+ required this.child,
|
|
|
+ required this.controller,
|
|
|
+ this.validateSteps = const [],
|
|
|
+ this.overlayContext,
|
|
|
+ }) : super(key: key);
|
|
|
+
|
|
|
+ final FWidget child;
|
|
|
+ final FFormItemValidatorController controller;
|
|
|
+ final List<FFormItemValidateStep> validateSteps;
|
|
|
+ final BuildContext? overlayContext;
|
|
|
+
|
|
|
+ @override
|
|
|
+ State<StatefulWidget> createState() => _FFormItemValidatorState();
|
|
|
+}
|
|
|
+
|
|
|
+class _FFormItemValidatorState extends State<FFormItemValidator> {
|
|
|
+ OverlayEntry? _overlayEntry;
|
|
|
+ late RenderBox _childBox;
|
|
|
+ late RenderBox _parentBox;
|
|
|
+ late Offset _childOffset;
|
|
|
+ final _arrowColor = Colors.black.withOpacity(.9);
|
|
|
+ static const _arrowSize = 10.0;
|
|
|
+ static const _verticalMargin = 0.0;
|
|
|
+ static const _horizontalMargin = 0.0;
|
|
|
+ bool _isShowCustomError = false;
|
|
|
+
|
|
|
+ BuildContext get _parentContext =>
|
|
|
+ widget.overlayContext ?? Overlay.of(context).context;
|
|
|
+
|
|
|
+ @override
|
|
|
+ void initState() {
|
|
|
+ super.initState();
|
|
|
+ widget.controller.addListener(_updateView);
|
|
|
+ WidgetsBinding.instance.addPostFrameCallback((call) {
|
|
|
+ if (mounted) {
|
|
|
+ _childBox = context.findRenderObject() as RenderBox;
|
|
|
+ _parentBox = _parentContext.findRenderObject() as RenderBox;
|
|
|
+ _childOffset = _childBox.localToGlobal(Offset.zero);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ FWidget build(BuildContext context) {
|
|
|
+ return widget.child;
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void dispose() {
|
|
|
+ _hideView();
|
|
|
+ widget.controller.removeListener(_updateView);
|
|
|
+ super.dispose();
|
|
|
+ }
|
|
|
+
|
|
|
+ void _updateView() {
|
|
|
+ if (widget.controller.isShowing) {
|
|
|
+ _hideView();
|
|
|
+ _isShowCustomError = false;
|
|
|
+ _showView();
|
|
|
+ } else {
|
|
|
+ _hideView();
|
|
|
+ if (widget.controller.needShowCustom) {
|
|
|
+ _isShowCustomError = true;
|
|
|
+ _showView();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void _showView() {
|
|
|
+ if (_overlayEntry == null) {
|
|
|
+ if (mounted) {
|
|
|
+ _childBox = context.findRenderObject() as RenderBox;
|
|
|
+ _parentBox = _parentContext.findRenderObject() as RenderBox;
|
|
|
+ _childOffset = _childBox.localToGlobal(Offset.zero);
|
|
|
+ }
|
|
|
+ _buildOverlayEntry();
|
|
|
+ final ctx = widget.overlayContext ?? context;
|
|
|
+ Overlay.of(ctx).insert(_overlayEntry!);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void _hideView() {
|
|
|
+ if (_overlayEntry != null) {
|
|
|
+ _overlayEntry?.remove();
|
|
|
+ _overlayEntry = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ void _buildOverlayEntry() {
|
|
|
+ _overlayEntry = OverlayEntry(
|
|
|
+ builder: (context) {
|
|
|
+ return IgnorePointer(
|
|
|
+ child: FContainer(
|
|
|
+ constraints: BoxConstraints(
|
|
|
+ maxWidth: _parentBox.size.width - 2 * _horizontalMargin,
|
|
|
+ maxHeight: _parentBox.size.height -
|
|
|
+ 2 * _verticalMargin -
|
|
|
+ _childOffset.dy -
|
|
|
+ _childBox.size.height,
|
|
|
+ ),
|
|
|
+ child: _buildTooltipLayout(),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ FWidget _buildTooltipConent() {
|
|
|
+ return FContainer(
|
|
|
+ padding: const EdgeInsets.all(10.0),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ borderRadius: BorderRadius.circular(8.0),
|
|
|
+ color: _arrowColor,
|
|
|
+ ),
|
|
|
+ width: _childBox.size.width - 2 * _horizontalMargin,
|
|
|
+ child: FColumn(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: widget.validateSteps
|
|
|
+ .map(
|
|
|
+ (e) => _ValidateStepWidget(
|
|
|
+ controller: widget.controller,
|
|
|
+ data: e,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ .toList(),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 自定义错误提示
|
|
|
+ FWidget _buildCustomTooltipConent() {
|
|
|
+ return FContainer(
|
|
|
+ padding: const EdgeInsets.all(10.0),
|
|
|
+ decoration: BoxDecoration(
|
|
|
+ borderRadius: BorderRadius.circular(8.0),
|
|
|
+ color: _arrowColor,
|
|
|
+ ),
|
|
|
+ width: _childBox.size.width - 2 * _horizontalMargin,
|
|
|
+ child: FColumn(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ _CustomValidateStepWidget(text: widget.controller.customErrorText)
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ FWidget _buildTooltipLayout() {
|
|
|
+ final arrow = ClipPath(
|
|
|
+ child: FContainer(
|
|
|
+ width: _arrowSize,
|
|
|
+ height: _arrowSize,
|
|
|
+ color: _arrowColor,
|
|
|
+ ),
|
|
|
+ clipper: _ArrowClipper(),
|
|
|
+ );
|
|
|
+ final content = _isShowCustomError
|
|
|
+ ? _buildCustomTooltipConent()
|
|
|
+ : _buildTooltipConent();
|
|
|
+
|
|
|
+ return FCustomMultiChildLayout(
|
|
|
+ delegate: _LayoutDelegate(
|
|
|
+ anchorSize: _childBox.size,
|
|
|
+ anchorOffset: _childOffset,
|
|
|
+ verticalMargin: _verticalMargin,
|
|
|
+ horizontalMargin: _horizontalMargin,
|
|
|
+ ),
|
|
|
+ children: <FWidget>[
|
|
|
+ FLayoutId(
|
|
|
+ id: _LayoutId.downArrow,
|
|
|
+ child: Transform.rotate(
|
|
|
+ angle: math.pi,
|
|
|
+ child: arrow,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ FLayoutId(
|
|
|
+ id: _LayoutId.content,
|
|
|
+ child: FColumn(
|
|
|
+ mainAxisSize: MainAxisSize.min,
|
|
|
+ children: <FWidget>[
|
|
|
+ FMaterial(
|
|
|
+ child: content,
|
|
|
+ color: Colors.transparent,
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+enum _LayoutId {
|
|
|
+ downArrow,
|
|
|
+ content,
|
|
|
+}
|
|
|
+
|
|
|
+class _LayoutDelegate extends MultiChildLayoutDelegate {
|
|
|
+ _LayoutDelegate({
|
|
|
+ required this.anchorSize,
|
|
|
+ required this.anchorOffset,
|
|
|
+ required this.verticalMargin,
|
|
|
+ required this.horizontalMargin,
|
|
|
+ });
|
|
|
+
|
|
|
+ final Size anchorSize;
|
|
|
+ final Offset anchorOffset;
|
|
|
+ final double verticalMargin;
|
|
|
+ final double horizontalMargin;
|
|
|
+
|
|
|
+ @override
|
|
|
+ void performLayout(Size size) {
|
|
|
+ Size contentSize = Size.zero;
|
|
|
+ Size arrowSize = Size.zero;
|
|
|
+ Offset contentOffset = Offset.zero;
|
|
|
+ Offset arrowOffset = Offset.zero;
|
|
|
+
|
|
|
+ double anchorTopY = anchorOffset.dy;
|
|
|
+
|
|
|
+ if (hasChild(_LayoutId.content)) {
|
|
|
+ contentSize = layoutChild(
|
|
|
+ _LayoutId.content,
|
|
|
+ BoxConstraints.loose(size),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ if (hasChild(_LayoutId.downArrow)) {
|
|
|
+ arrowSize = layoutChild(
|
|
|
+ _LayoutId.downArrow,
|
|
|
+ BoxConstraints.loose(size),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ arrowOffset = Offset(
|
|
|
+ anchorOffset.dx +
|
|
|
+ anchorSize.width -
|
|
|
+ horizontalMargin * 2 -
|
|
|
+ arrowSize.width * 2,
|
|
|
+ anchorTopY - verticalMargin - arrowSize.height,
|
|
|
+ );
|
|
|
+ contentOffset = Offset(
|
|
|
+ anchorOffset.dx + horizontalMargin,
|
|
|
+ anchorTopY - verticalMargin - arrowSize.height - contentSize.height,
|
|
|
+ );
|
|
|
+
|
|
|
+ if (hasChild(_LayoutId.content)) {
|
|
|
+ positionChild(_LayoutId.content, contentOffset);
|
|
|
+ }
|
|
|
+ if (hasChild(_LayoutId.downArrow)) {
|
|
|
+ positionChild(
|
|
|
+ _LayoutId.downArrow,
|
|
|
+ Offset(arrowOffset.dx, arrowOffset.dy - 0.1),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => false;
|
|
|
+}
|
|
|
+
|
|
|
+class _ArrowClipper extends CustomClipper<Path> {
|
|
|
+ @override
|
|
|
+ Path getClip(Size size) {
|
|
|
+ Path path = Path();
|
|
|
+ path.moveTo(0, size.height);
|
|
|
+ path.lineTo(size.width / 2, size.height / 2);
|
|
|
+ path.lineTo(size.width, size.height);
|
|
|
+ return path;
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ bool shouldReclip(CustomClipper<Path> oldClipper) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class _ValidateStepWidget extends FStatefulWidget {
|
|
|
+ const _ValidateStepWidget({
|
|
|
+ Key? key,
|
|
|
+ required this.controller,
|
|
|
+ required this.data,
|
|
|
+ }) : super(key: key);
|
|
|
+
|
|
|
+ final FFormItemValidatorController controller;
|
|
|
+ final FFormItemValidateStep data;
|
|
|
+
|
|
|
+ @override
|
|
|
+ FState<_ValidateStepWidget> createState() => _ValidateStepWidgetState();
|
|
|
+}
|
|
|
+
|
|
|
+class _ValidateStepWidgetState extends FState<_ValidateStepWidget> {
|
|
|
+ late final TextStyle _textStyle;
|
|
|
+ static const Color _initialColor = Colors.white;
|
|
|
+ static const Color _rightColor = Color(0xFF66BB6A);
|
|
|
+ static const Color _errorColor = Color(0xFFEF5350);
|
|
|
+
|
|
|
+ @override
|
|
|
+ void initState() {
|
|
|
+ _textStyle = TextStyle(
|
|
|
+ fontFamily: FTheme.ins.localeSetting.fontFamily,
|
|
|
+ fontSize: 13,
|
|
|
+ color: _initialColor,
|
|
|
+ );
|
|
|
+
|
|
|
+ super.initState();
|
|
|
+
|
|
|
+ widget.controller.addListener(_updateView);
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ FWidget build(BuildContext context) {
|
|
|
+ if (!widget.controller.isValidating) {
|
|
|
+ return FText(widget.data.tips, style: _textStyle);
|
|
|
+ }
|
|
|
+ final checked = widget.data.validateFn();
|
|
|
+ final color = checked ? _rightColor : _errorColor;
|
|
|
+
|
|
|
+ return FRow(
|
|
|
+ children: [
|
|
|
+ FIcon(
|
|
|
+ checked ? Icons.check_rounded : Icons.close_rounded,
|
|
|
+ color: color,
|
|
|
+ size: 13,
|
|
|
+ ),
|
|
|
+ FExpanded(
|
|
|
+ child: FText(
|
|
|
+ widget.data.tips,
|
|
|
+ style: _textStyle.copyWith(
|
|
|
+ color: color,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ void _updateView() {
|
|
|
+ if (widget.controller.isShowing) {
|
|
|
+ setState(() {});
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @override
|
|
|
+ void dispose() {
|
|
|
+ widget.controller.removeListener(_updateView);
|
|
|
+ super.dispose();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+class _CustomValidateStepWidget extends FStatelessWidget {
|
|
|
+ _CustomValidateStepWidget({
|
|
|
+ Key? key,
|
|
|
+ required this.text,
|
|
|
+ }) : super(key: key);
|
|
|
+
|
|
|
+ final String text;
|
|
|
+ final _textStyle = TextStyle(
|
|
|
+ fontFamily: FTheme.ins.localeSetting.fontFamily,
|
|
|
+ fontSize: 13,
|
|
|
+ color: const Color(0xFFEF5350),
|
|
|
+ );
|
|
|
+
|
|
|
+ @override
|
|
|
+ FWidget build(BuildContext context) {
|
|
|
+ return FRow(
|
|
|
+ children: [
|
|
|
+ const FIcon(
|
|
|
+ Icons.close_rounded,
|
|
|
+ color: Color(0xFFEF5350),
|
|
|
+ size: 13,
|
|
|
+ ),
|
|
|
+ FExpanded(
|
|
|
+ child: FText(text, style: _textStyle),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|