diff --git a/modules/ensemble/lib/controller/controller_mixins.dart b/modules/ensemble/lib/controller/controller_mixins.dart index 688c99a39..7a34fdc6b 100644 --- a/modules/ensemble/lib/controller/controller_mixins.dart +++ b/modules/ensemble/lib/controller/controller_mixins.dart @@ -18,6 +18,7 @@ mixin HasBorderController on EnsembleWidgetController { Color? borderColor; int? borderWidth; EBorderRadius? borderRadius; + BorderStyleComposite? borderStyle; Map hasBorderSetters() => { 'borderGradient': (value) => @@ -25,6 +26,8 @@ mixin HasBorderController on EnsembleWidgetController { 'borderColor': (value) => borderColor = Utils.getColor(value), 'borderWidth': (value) => borderWidth = Utils.optionalInt(value), 'borderRadius': (value) => borderRadius = Utils.getBorderRadius(value), + 'borderStyle': (value) => + borderStyle = BorderStyleComposite.from(this, value), }; bool hasBorder() => diff --git a/modules/ensemble/lib/widget/helpers/box_wrapper.dart b/modules/ensemble/lib/widget/helpers/box_wrapper.dart index fb80626c2..d8cbc0651 100644 --- a/modules/ensemble/lib/widget/helpers/box_wrapper.dart +++ b/modules/ensemble/lib/widget/helpers/box_wrapper.dart @@ -8,6 +8,37 @@ import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:ensemble/widget/helpers/widgets.dart'; import 'package:flutter/material.dart'; +BoxBorder? _resolveBoxBorder({ + required bool hasBorder, + required LinearGradient? borderGradient, + required Color? borderColor, + required int? borderWidth, + required BorderStyleComposite? borderStyle, + required BuildContext context, +}) { + if (!hasBorder) return null; + + final width = + borderWidth?.toDouble() ?? ThemeManager().getBorderThickness(context); + + if (borderGradient != null) { + return GradientBoxBorder(gradient: borderGradient, width: width); + } + + final color = borderColor ?? ThemeManager().getBorderColor(context); + if (borderStyle != null && borderStyle.isNonSolid) { + return DashedBoxBorder( + color: color, + width: width, + style: borderStyle.type, + dashLength: borderStyle.effectiveLength, + gap: borderStyle.effectiveGap, + ); + } + + return Border.all(color: color, width: width); +} + /// TODO: Legacy - move to EnsembleBoxWrapper /// wraps around a widget and gives it common box attributes class BoxWrapper extends StatelessWidget { @@ -76,18 +107,14 @@ class BoxWrapper extends StatelessWidget { : BoxDecoration( color: boxController.backgroundColor, gradient: boxController.backgroundGradient, - border: !boxController.hasBorder() - ? null - : boxController.borderGradient != null - ? GradientBoxBorder( - gradient: boxController.borderGradient!, - width: boxController.borderWidth?.toDouble() ?? - ThemeManager().getBorderThickness(context)) - : Border.all( - color: boxController.borderColor ?? - ThemeManager().getBorderColor(context), - width: boxController.borderWidth?.toDouble() ?? - ThemeManager().getBorderThickness(context)), + border: _resolveBoxBorder( + hasBorder: boxController.hasBorder(), + borderGradient: boxController.borderGradient, + borderColor: boxController.borderColor, + borderWidth: boxController.borderWidth, + borderStyle: boxController.borderStyle, + context: context, + ), borderRadius: boxController.borderRadius?.getValue(), boxShadow: !boxController.hasBoxShadow() ? null @@ -271,18 +298,14 @@ class EnsembleBoxWrapper extends StatelessWidget { : BoxDecoration( color: boxController.backgroundColor, gradient: boxController.backgroundGradient, - border: !boxController.hasBorder() - ? null - : boxController.borderGradient != null - ? GradientBoxBorder( - gradient: boxController.borderGradient!, - width: boxController.borderWidth?.toDouble() ?? - ThemeManager().getBorderThickness(context)) - : Border.all( - color: boxController.borderColor ?? - ThemeManager().getBorderColor(context), - width: boxController.borderWidth?.toDouble() ?? - ThemeManager().getBorderThickness(context)), + border: _resolveBoxBorder( + hasBorder: boxController.hasBorder(), + borderGradient: boxController.borderGradient, + borderColor: boxController.borderColor, + borderWidth: boxController.borderWidth, + borderStyle: boxController.borderStyle, + context: context, + ), borderRadius: _borderRadius?.getValue(), boxShadow: boxController.boxShadow == null ? null diff --git a/modules/ensemble/lib/widget/helpers/controllers.dart b/modules/ensemble/lib/widget/helpers/controllers.dart index 79b29c534..1fb5ce620 100644 --- a/modules/ensemble/lib/widget/helpers/controllers.dart +++ b/modules/ensemble/lib/widget/helpers/controllers.dart @@ -34,6 +34,65 @@ abstract class WidgetCompositeProperty with Invokable { } } +/// Nested borderStyle map: type / length / gap +class BorderStyleComposite extends WidgetCompositeProperty { + BorderStyleComposite._(super.widgetController, + {BoxBorderStyle? type, this.length, this.gap}) + : type = type ?? BoxBorderStyle.solid; + + static const double defaultLength = 5; + static const double defaultGap = 3; + + BoxBorderStyle type; + double? length; + double? gap; + + factory BorderStyleComposite.from( + ChangeNotifier widgetController, dynamic payload) { + if (payload is Map) { + return BorderStyleComposite._( + widgetController, + type: BoxBorderStyle.values.from(payload['type']) ?? BoxBorderStyle.solid, + length: Utils.optionalDouble(payload['length']), + gap: Utils.optionalDouble(payload['gap']), + ); + } + // shorthand: borderStyle: dashed + if (payload is String) { + return BorderStyleComposite._( + widgetController, + type: BoxBorderStyle.values.from(payload) ?? BoxBorderStyle.solid, + ); + } + return BorderStyleComposite._(widgetController); + } + + bool get isNonSolid => + type == BoxBorderStyle.dashed || type == BoxBorderStyle.dotted; + + double get effectiveLength => length ?? defaultLength; + + double get effectiveGap => gap ?? defaultGap; + + @override + Map getters() => { + 'type': () => type.name, + 'length': () => length, + 'gap': () => gap, + }; + + @override + Map methods() => {}; + + @override + Map setters() => { + 'type': (value) => + type = BoxBorderStyle.values.from(value) ?? BoxBorderStyle.solid, + 'length': (value) => length = Utils.optionalDouble(value), + 'gap': (value) => gap = Utils.optionalDouble(value), + }; +} + class BoxShadowComposite extends WidgetCompositeProperty { BoxShadowComposite(super.widgetController, {required Map inputs}) { color = inputs['color']; @@ -409,6 +468,7 @@ class BoxController extends WidgetController { int? borderWidth; EBorderRadius? borderRadius; LinearGradient? borderGradient; + BorderStyleComposite? borderStyle; BoxShadowComposite? boxShadow; @@ -449,6 +509,8 @@ class BoxController extends WidgetController { 'borderColor': (value) => borderColor = Utils.getColor(value), 'borderWidth': (value) => borderWidth = Utils.optionalInt(value), 'borderRadius': (value) => borderRadius = Utils.getBorderRadius(value), + 'borderStyle': (value) => + borderStyle = BorderStyleComposite.from(this, value), 'boxShadow': (value) => boxShadow = Utils.getBoxShadowComposite(this, value), @@ -676,6 +738,13 @@ class EnsembleBoxController extends EnsembleWidgetController boxShadow != null; } +/// Border stroke pattern for container box styles. +enum BoxBorderStyle { + solid, + dashed, + dotted, +} + class EnsembleSemantics { bool focusable; String? label; diff --git a/modules/ensemble/lib/widget/helpers/widgets.dart b/modules/ensemble/lib/widget/helpers/widgets.dart index f8ac016aa..418f0e700 100644 --- a/modules/ensemble/lib/widget/helpers/widgets.dart +++ b/modules/ensemble/lib/widget/helpers/widgets.dart @@ -1,4 +1,5 @@ /// This class contains common widgets for use with Ensemble widgets. +import 'package:ensemble/widget/helpers/controllers.dart'; import 'package:flutter/material.dart'; /// Display a Text content followed by a clear icon. @@ -90,6 +91,91 @@ class GradientBoxBorder extends BoxBorder with GradientBorder { } } +/// Draws dashed or dotted borders for container box styles. +class DashedBoxBorder extends BoxBorder { + const DashedBoxBorder({ + required this.color, + required this.width, + required this.style, + this.dashLength = BorderStyleComposite.defaultLength, + this.gap = BorderStyleComposite.defaultGap, + }); + + final Color color; + final double width; + final BoxBorderStyle style; + final double dashLength; + final double gap; + + @override + BorderSide get bottom => BorderSide.none; + + @override + BorderSide get top => BorderSide.none; + + @override + bool get isUniform => true; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.all(width); + + @override + void paint( + Canvas canvas, + Rect rect, { + TextDirection? textDirection, + BoxShape shape = BoxShape.rectangle, + BorderRadius? borderRadius, + }) { + final path = Path(); + if (borderRadius != null) { + path.addRRect(borderRadius.toRRect(rect).deflate(width / 2)); + } else { + path.addRect(rect.deflate(width / 2)); + } + + final paint = Paint() + ..color = color + ..strokeWidth = width + ..style = PaintingStyle.stroke + ..strokeCap = + style == BoxBorderStyle.dotted ? StrokeCap.round : StrokeCap.butt; + + final dash = + style == BoxBorderStyle.dotted ? width : dashLength; + final gapLength = style == BoxBorderStyle.dotted + ? (gap < width ? width : gap) + : gap; + + for (final metric in path.computeMetrics()) { + double distance = 0; + bool draw = true; + while (distance < metric.length) { + final segment = draw ? dash : gapLength; + final next = distance + segment; + if (draw) { + canvas.drawPath( + metric.extractPath(distance, next.clamp(0, metric.length)), + paint); + } + distance = next; + draw = !draw; + } + } + } + + @override + ShapeBorder scale(double t) { + return DashedBoxBorder( + color: color, + width: width * t, + style: style, + dashLength: dashLength * t, + gap: gap * t, + ); + } +} + /// a wrapper around a widget and enable Tap action. class TapOverlay extends StatelessWidget { const TapOverlay({super.key, required this.widget, required this.onTap});