diff --git a/example/lib/src/storybook/stories/popover.dart b/example/lib/src/storybook/stories/popover.dart new file mode 100644 index 00000000..9a736459 --- /dev/null +++ b/example/lib/src/storybook/stories/popover.dart @@ -0,0 +1,132 @@ +import 'package:example/src/storybook/common/options.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; +import 'package:storybook_flutter/storybook_flutter.dart'; + +class PopoverStory extends Story { + PopoverStory() + : super( + name: "Popover", + builder: (context) { + final customLabelTextKnob = context.knobs.text( + label: "Custom label text", + initial: "Custom popover text", + ); + + final popoverPositionsKnob = context.knobs.options( + label: "popoverPosition", + description: "Popover position variants.", + initial: MoonPopoverPosition.top, + options: const [ + Option(label: "top", value: MoonPopoverPosition.top), + Option(label: "bottom", value: MoonPopoverPosition.bottom), + Option(label: "left", value: MoonPopoverPosition.left), + Option(label: "right", value: MoonPopoverPosition.right), + Option(label: "topLeft", value: MoonPopoverPosition.topLeft), + Option(label: "topRight", value: MoonPopoverPosition.topRight), + Option(label: "bottomLeft", value: MoonPopoverPosition.bottomLeft), + Option(label: "bottomRight", value: MoonPopoverPosition.bottomRight), + Option(label: "vertical", value: MoonPopoverPosition.vertical), + Option(label: "horizontal", value: MoonPopoverPosition.horizontal), + ], + ); + + final colorsKnob = context.knobs.options( + label: "backgroundColor", + description: "MoonColors variants for Popover background.", + initial: 4, // gohan + options: colorOptions, + ); + + final color = colorTable(context)[colorsKnob]; + + final borderRadiusKnob = context.knobs.sliderInt( + max: 20, + initial: 8, + label: "borderRadius", + description: "Border radius for Popover.", + ); + + final distanceToTargetKnob = context.knobs.slider( + label: "distanceToTarget", + description: "Set the distance to target child widget.", + initial: 8, + max: 100, + ); + + final showShadowKnob = context.knobs.boolean( + label: "Show shadow", + description: "Show shadows under the Popover.", + initial: true, + ); + + final setRtlModeKnob = context.knobs.boolean( + label: "RTL mode", + description: "Switch between LTR and RTL modes.", + ); + + bool show = true; + + return Directionality( + textDirection: setRtlModeKnob ? TextDirection.rtl : TextDirection.ltr, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 64), + StatefulBuilder( + builder: (context, setState) { + return MoonPopover( + show: show, + backgroundColor: color, + borderRadius: BorderRadius.circular(borderRadiusKnob.toDouble()), + distanceToTarget: distanceToTargetKnob, + popoverPosition: popoverPositionsKnob, + popoverShadows: showShadowKnob == true ? null : [], + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 190), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + textDirection: Directionality.of(context), + mainAxisSize: MainAxisSize.min, + children: [ + MoonAvatar( + backgroundColor: context.moonColors?.heles, + child: const Icon(MoonIconsOther.rocket24), + ), + const SizedBox(width: 12), + Expanded(child: Text(customLabelTextKnob)), + ], + ), + const SizedBox(height: 16), + MoonPrimaryButton( + buttonSize: MoonButtonSize.sm, + isFullWidth: true, + onTap: () { + setState(() => show = false); + }, + label: const Text("Close"), + ), + ], + ), + ), + child: MoonButton( + backgroundColor: context.moonColors!.bulma, + onTap: () { + setState(() => show = true); + }, + label: const Text("MDS"), + ), + ); + }, + ), + const SizedBox(height: 64), + ], + ), + ), + ); + }, + ); +} diff --git a/example/lib/src/storybook/stories/tooltip.dart b/example/lib/src/storybook/stories/tooltip.dart index 041758d8..9b7d500d 100644 --- a/example/lib/src/storybook/stories/tooltip.dart +++ b/example/lib/src/storybook/stories/tooltip.dart @@ -34,50 +34,58 @@ class TooltipStory extends Story { final colorsKnob = context.knobs.options( label: "backgroundColor", - description: "MoonColors variants for base MoonButton.", + description: "MoonColors variants for Tooltip background.", initial: 4, // gohan options: colorOptions, ); final color = colorTable(context)[colorsKnob]; + final borderRadiusKnob = context.knobs.sliderInt( + max: 20, + initial: 8, + label: "borderRadius", + description: "Border radius for Tooltip.", + ); + final arrowOffsetKnob = context.knobs.slider( label: "arrowOffsetValue", - description: "Set the offset of the tooltip arrow.", + description: "Set the offset of the Tooltip arrow.", initial: 0, min: -100, max: 100, ); + final arrowTipDistanceKnob = context.knobs.slider( + label: "arrowTipDistance", + description: "Set the distance to target child widget.", + initial: 8, + max: 100, + ); + final arrowBaseWidthKnob = context.knobs.slider( label: "arrowBaseWidth", - description: "Set the base width of the tooltip arrow.", + description: "Set the base width of the Tooltip arrow.", initial: 16, max: 100, ); final arrowLengthKnob = context.knobs.slider( label: "arrowLength", - description: "Set the length of the tooltip arrow.", + description: "Set the length of the Tooltip arrow.", initial: 8, max: 100, ); - final showTooltipKnob = context.knobs.boolean( - label: "show", - description: "Show the tooltip.", + final showShadowKnob = context.knobs.boolean( + label: "Show shadow", + description: "Show shadows under the Tooltip.", initial: true, ); final showArrowKnob = context.knobs.boolean( label: "hasArrow", - description: "Does tooltip have an arrow (tail).", - initial: true, - ); - - final showShadowKnob = context.knobs.boolean( - label: "Show shadow", - description: "Show shadows under the tooltip.", + description: "Does Tooltip have an arrow (tail).", initial: true, ); @@ -86,6 +94,8 @@ class TooltipStory extends Story { description: "Switch between LTR and RTL modes.", ); + bool show = true; + return Directionality( textDirection: setRtlModeKnob ? TextDirection.rtl : TextDirection.ltr, child: Center( @@ -95,21 +105,29 @@ class TooltipStory extends Story { const SizedBox(height: 64), const TextDivider(text: "Customisable tooltip"), const SizedBox(height: 32), - MoonTooltip( - arrowBaseWidth: arrowBaseWidthKnob, - arrowLength: arrowLengthKnob, - arrowOffsetValue: arrowOffsetKnob, - show: showTooltipKnob, - tooltipPosition: tooltipPositionsKnob, - hasArrow: showArrowKnob, - backgroundColor: color, - tooltipShadows: showShadowKnob == true ? null : [], - content: Text(customLabelTextKnob), - child: MoonButton( - backgroundColor: context.moonColors!.bulma, - onTap: () {}, - label: const Text("MDS"), - ), + StatefulBuilder( + builder: (context, setState) { + return MoonTooltip( + show: show, + backgroundColor: color, + borderRadiusValue: borderRadiusKnob.toDouble(), + tooltipPosition: tooltipPositionsKnob, + hasArrow: showArrowKnob, + arrowBaseWidth: arrowBaseWidthKnob, + arrowLength: arrowLengthKnob, + arrowOffsetValue: arrowOffsetKnob, + arrowTipDistance: arrowTipDistanceKnob, + tooltipShadows: showShadowKnob == true ? null : [], + content: Text(customLabelTextKnob), + child: MoonButton( + backgroundColor: context.moonColors!.bulma, + onTap: () { + setState(() => show = true); + }, + label: const Text("MDS"), + ), + ); + }, ), const SizedBox(height: 40), const TextDivider(text: "Default tooltip"), diff --git a/example/lib/src/storybook/storybook.dart b/example/lib/src/storybook/storybook.dart index 3c58c1e4..6650fe36 100644 --- a/example/lib/src/storybook/storybook.dart +++ b/example/lib/src/storybook/storybook.dart @@ -7,6 +7,7 @@ import 'package:example/src/storybook/stories/circular_progress.dart'; import 'package:example/src/storybook/stories/icons.dart'; import 'package:example/src/storybook/stories/linear_loader.dart'; import 'package:example/src/storybook/stories/linear_progress.dart'; +import 'package:example/src/storybook/stories/popover.dart'; import 'package:example/src/storybook/stories/tag.dart'; import 'package:example/src/storybook/stories/tooltip.dart'; import 'package:flutter/material.dart'; @@ -71,6 +72,7 @@ class StorybookPage extends StatelessWidget { IconsStory(), LinearLoaderStory(), LinearProgressStory(), + PopoverStory(), TagStory(), TooltipStory(), ], diff --git a/lib/moon_design.dart b/lib/moon_design.dart index cc29680a..181b7cb0 100644 --- a/lib/moon_design.dart +++ b/lib/moon_design.dart @@ -9,12 +9,13 @@ export 'package:moon_design/src/theme/effects/effects.dart'; export 'package:moon_design/src/theme/loaders/circular_loader/circular_loader_theme.dart'; export 'package:moon_design/src/theme/loaders/linear_loader/linear_loader_theme.dart'; export 'package:moon_design/src/theme/opacity.dart'; +export 'package:moon_design/src/theme/popover/popover_theme.dart'; export 'package:moon_design/src/theme/progress/circular_progress/circular_progress_theme.dart'; export 'package:moon_design/src/theme/progress/linear_progress/linear_progress_theme.dart'; export 'package:moon_design/src/theme/shadows.dart'; export 'package:moon_design/src/theme/sizes.dart'; export 'package:moon_design/src/theme/theme.dart'; -export 'package:moon_design/src/theme/tooltip/tooltip_properties.dart'; +export 'package:moon_design/src/theme/tooltip/tooltip_theme.dart'; export 'package:moon_design/src/theme/typography/text_colors.dart'; export 'package:moon_design/src/theme/typography/text_styles.dart'; export 'package:moon_design/src/theme/typography/typography.dart'; @@ -40,6 +41,7 @@ export 'package:moon_design/src/widgets/common/progress_indicators/circular_prog export 'package:moon_design/src/widgets/common/progress_indicators/linear_progress_indicator.dart'; export 'package:moon_design/src/widgets/loaders/circular_loader.dart'; export 'package:moon_design/src/widgets/loaders/linear_loader.dart'; +export 'package:moon_design/src/widgets/popover/popover.dart'; export 'package:moon_design/src/widgets/progress/circular_progress.dart'; export 'package:moon_design/src/widgets/progress/linear_progress.dart'; export 'package:moon_design/src/widgets/tag/tag.dart'; diff --git a/lib/src/theme/popover/popover_colors.dart b/lib/src/theme/popover/popover_colors.dart new file mode 100644 index 00000000..44d7328b --- /dev/null +++ b/lib/src/theme/popover/popover_colors.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/colors.dart'; + +@immutable +class MoonPopoverColors extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonPopoverColors( + backgroundColor: MoonColors.light.gohan, + ); + + static final dark = MoonPopoverColors( + backgroundColor: MoonColors.dark.gohan, + ); + + /// Popover background color. + final Color backgroundColor; + + const MoonPopoverColors({ + required this.backgroundColor, + }); + + @override + MoonPopoverColors copyWith({Color? backgroundColor}) { + return MoonPopoverColors( + backgroundColor: backgroundColor ?? this.backgroundColor, + ); + } + + @override + MoonPopoverColors lerp(ThemeExtension? other, double t) { + if (other is! MoonPopoverColors) return this; + + return MoonPopoverColors( + backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonPopoverColors")) + ..add(ColorProperty("backgroundColor", backgroundColor)); + } +} diff --git a/lib/src/theme/popover/popover_properties.dart b/lib/src/theme/popover/popover_properties.dart new file mode 100644 index 00000000..fce71312 --- /dev/null +++ b/lib/src/theme/popover/popover_properties.dart @@ -0,0 +1,83 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/borders.dart'; +import 'package:moon_design/src/theme/sizes.dart'; + +@immutable +class MoonPopoverProperties extends ThemeExtension with DiagnosticableTreeMixin { + static final properties = MoonPopoverProperties( + distanceToTarget: MoonSizes.sizes.x4s, + contentPadding: EdgeInsets.all(MoonSizes.sizes.x3s), + borderRadius: MoonBorders.borders.interactiveMd, + transitionDuration: const Duration(milliseconds: 150), + transitionCurve: Curves.easeInOutCubic, + ); + + /// Popover distance to target child widget. + final double distanceToTarget; + + /// Padding around popover content. + final EdgeInsets contentPadding; + + /// Popover border radius. + final BorderRadius borderRadius; + + /// Popover transition duration (fade in or out animation). + final Duration transitionDuration; + + /// Popover transition curve (fade in or out animation). + final Curve transitionCurve; + + const MoonPopoverProperties({ + required this.distanceToTarget, + required this.contentPadding, + required this.borderRadius, + required this.transitionDuration, + required this.transitionCurve, + }); + + @override + MoonPopoverProperties copyWith({ + double? distanceToTarget, + EdgeInsets? contentPadding, + BorderRadius? borderRadius, + Duration? transitionDuration, + Curve? transitionCurve, + }) { + return MoonPopoverProperties( + distanceToTarget: distanceToTarget ?? this.distanceToTarget, + contentPadding: contentPadding ?? this.contentPadding, + borderRadius: borderRadius ?? this.borderRadius, + transitionDuration: transitionDuration ?? this.transitionDuration, + transitionCurve: transitionCurve ?? this.transitionCurve, + ); + } + + @override + MoonPopoverProperties lerp(ThemeExtension? other, double t) { + if (other is! MoonPopoverProperties) return this; + + return MoonPopoverProperties( + distanceToTarget: lerpDouble(distanceToTarget, other.distanceToTarget, t)!, + contentPadding: EdgeInsets.lerp(contentPadding, other.contentPadding, t)!, + borderRadius: BorderRadius.lerp(borderRadius, other.borderRadius, t)!, + transitionDuration: lerpDuration(transitionDuration, other.transitionDuration, t), + transitionCurve: other.transitionCurve, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonPopoverProperties")) + ..add(DoubleProperty("distanceToTarget", distanceToTarget)) + ..add(DiagnosticsProperty("contentPadding", contentPadding)) + ..add(DiagnosticsProperty("borderRadius", borderRadius)) + ..add(DiagnosticsProperty("transitionDuration", transitionDuration)) + ..add(DiagnosticsProperty("transitionCurve", transitionCurve)); + } +} diff --git a/lib/src/theme/popover/popover_shadows.dart b/lib/src/theme/popover/popover_shadows.dart new file mode 100644 index 00000000..715fadd5 --- /dev/null +++ b/lib/src/theme/popover/popover_shadows.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/shadows.dart'; + +@immutable +class MoonPopoverShadows extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonPopoverShadows( + popoverShadows: MoonShadows.light.sm, + ); + + static final dark = MoonPopoverShadows( + popoverShadows: MoonShadows.dark.sm, + ); + + /// Popover shadows. + final List popoverShadows; + + const MoonPopoverShadows({ + required this.popoverShadows, + }); + + @override + MoonPopoverShadows copyWith({List? popoverShadows}) { + return MoonPopoverShadows( + popoverShadows: popoverShadows ?? this.popoverShadows, + ); + } + + @override + MoonPopoverShadows lerp(ThemeExtension? other, double t) { + if (other is! MoonPopoverShadows) return this; + + return MoonPopoverShadows( + popoverShadows: BoxShadow.lerpList(popoverShadows, other.popoverShadows, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonPopoverShadows")) + ..add(DiagnosticsProperty>("shadows", popoverShadows)); + } +} diff --git a/lib/src/theme/popover/popover_theme.dart b/lib/src/theme/popover/popover_theme.dart new file mode 100644 index 00000000..628863ef --- /dev/null +++ b/lib/src/theme/popover/popover_theme.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/popover/popover_colors.dart'; +import 'package:moon_design/src/theme/popover/popover_properties.dart'; +import 'package:moon_design/src/theme/popover/popover_shadows.dart'; + +@immutable +class MoonPopoverTheme extends ThemeExtension with DiagnosticableTreeMixin { + static final light = MoonPopoverTheme( + colors: MoonPopoverColors.light, + shadows: MoonPopoverShadows.light, + properties: MoonPopoverProperties.properties, + ); + + static final dark = MoonPopoverTheme( + colors: MoonPopoverColors.dark, + shadows: MoonPopoverShadows.dark, + properties: MoonPopoverProperties.properties, + ); + + /// Popover colors. + final MoonPopoverColors colors; + + /// Popover shadows. + final MoonPopoverShadows shadows; + + /// Popover properties. + final MoonPopoverProperties properties; + + const MoonPopoverTheme({ + required this.colors, + required this.shadows, + required this.properties, + }); + + @override + MoonPopoverTheme copyWith({ + MoonPopoverColors? colors, + MoonPopoverShadows? shadows, + MoonPopoverProperties? properties, + }) { + return MoonPopoverTheme( + colors: colors ?? this.colors, + shadows: shadows ?? this.shadows, + properties: properties ?? this.properties, + ); + } + + @override + MoonPopoverTheme lerp(ThemeExtension? other, double t) { + if (other is! MoonPopoverTheme) return this; + + return MoonPopoverTheme( + colors: colors.lerp(other.colors, t), + shadows: shadows.lerp(other.shadows, t), + properties: properties.lerp(other.properties, t), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder diagnosticProperties) { + super.debugFillProperties(diagnosticProperties); + diagnosticProperties + ..add(DiagnosticsProperty("type", "MoonPopoverTheme")) + ..add(DiagnosticsProperty("colors", colors)) + ..add(DiagnosticsProperty("shadows", shadows)) + ..add(DiagnosticsProperty("properties", properties)); + } +} diff --git a/lib/src/theme/theme.dart b/lib/src/theme/theme.dart index 2e2ac90f..1bfa0e19 100644 --- a/lib/src/theme/theme.dart +++ b/lib/src/theme/theme.dart @@ -10,6 +10,7 @@ import 'package:moon_design/src/theme/effects/effects.dart'; import 'package:moon_design/src/theme/loaders/circular_loader/circular_loader_theme.dart'; import 'package:moon_design/src/theme/loaders/linear_loader/linear_loader_theme.dart'; import 'package:moon_design/src/theme/opacity.dart'; +import 'package:moon_design/src/theme/popover/popover_theme.dart'; import 'package:moon_design/src/theme/progress/circular_progress/circular_progress_theme.dart'; import 'package:moon_design/src/theme/progress/linear_progress/linear_progress_theme.dart'; import 'package:moon_design/src/theme/shadows.dart'; @@ -32,6 +33,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { linearLoader: MoonLinearLoaderTheme.light, linearProgress: MoonLinearProgressTheme.light, opacity: MoonOpacity.opacities, + popover: MoonPopoverTheme.light, shadows: MoonShadows.light, sizes: MoonSizes.sizes, tag: MoonTagTheme.light, @@ -51,6 +53,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { linearLoader: MoonLinearLoaderTheme.dark, linearProgress: MoonLinearProgressTheme.dark, opacity: MoonOpacity.opacities, + popover: MoonPopoverTheme.dark, shadows: MoonShadows.dark, sizes: MoonSizes.sizes, tag: MoonTagTheme.dark, @@ -91,6 +94,9 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System opacities. final MoonOpacity opacity; + /// Moon Design System popover theming. + final MoonPopoverTheme popover; + /// Moon Design System shadows. final MoonShadows shadows; @@ -118,6 +124,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { required this.linearLoader, required this.linearProgress, required this.opacity, + required this.popover, required this.shadows, required this.sizes, required this.tag, @@ -138,6 +145,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonLinearLoaderTheme? linearLoader, MoonLinearProgressTheme? linearProgress, MoonOpacity? opacity, + MoonPopoverTheme? popover, MoonShadows? shadows, MoonSizes? sizes, MoonTagTheme? tag, @@ -156,6 +164,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { linearLoader: linearLoader ?? this.linearLoader, linearProgress: linearProgress ?? this.linearProgress, opacity: opacity ?? this.opacity, + popover: popover ?? this.popover, shadows: shadows ?? this.shadows, sizes: sizes ?? this.sizes, tag: tag ?? this.tag, @@ -180,6 +189,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { linearLoader: linearLoader.lerp(other.linearLoader, t), linearProgress: linearProgress.lerp(other.linearProgress, t), opacity: opacity.lerp(other.opacity, t), + popover: popover.lerp(other.popover, t), shadows: shadows.lerp(other.shadows, t), sizes: sizes.lerp(other.sizes, t), tag: tag.lerp(other.tag, t), @@ -204,6 +214,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { ..add(DiagnosticsProperty("MoonLinearLoaderTheme", linearLoader)) ..add(DiagnosticsProperty("MoonLinearProgressTheme", linearProgress)) ..add(DiagnosticsProperty("MoonOpacity", opacity)) + ..add(DiagnosticsProperty("MoonPopoverTheme", popover)) ..add(DiagnosticsProperty("MoonShadows", shadows)) ..add(DiagnosticsProperty("MoonSizes", sizes)) ..add(DiagnosticsProperty("MoonTagTheme", tag)) diff --git a/lib/src/widgets/common/base_control.dart b/lib/src/widgets/common/base_control.dart index b118cccc..cf10279c 100644 --- a/lib/src/widgets/common/base_control.dart +++ b/lib/src/widgets/common/base_control.dart @@ -220,6 +220,8 @@ class _MoonBaseControlState extends State { } void _handleLongPress() { + if (widget.onLongPress == null) return; + if (_isEnabled) { widget.onLongPress?.call(); } @@ -236,6 +238,10 @@ class _MoonBaseControlState extends State { } void _handleLongPressUp() { + if (widget.onLongPress == null) { + widget.onTap?.call(); + } + if (_isLongPressed && mounted) { setState(() => _isLongPressed = false); } diff --git a/lib/src/widgets/popover/popover.dart b/lib/src/widgets/popover/popover.dart new file mode 100644 index 00000000..6793b69e --- /dev/null +++ b/lib/src/widgets/popover/popover.dart @@ -0,0 +1,522 @@ +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/colors.dart'; +import 'package:moon_design/src/theme/shadows.dart'; +import 'package:moon_design/src/theme/theme.dart'; + +enum MoonPopoverPosition { + top, + topLeft, + topRight, + bottom, + bottomLeft, + bottomRight, + left, + right, + vertical, + horizontal, +} + +class MoonPopover extends StatefulWidget { + // This is required so only one popover is shown at a time. + static final List _openedPopovers = []; + + /// Controls the popover visibility. + final bool show; + + /// Sets the popover position relative to the target. Defaults to [MoonPopoverPosition.vertical] + final MoonPopoverPosition popoverPosition; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? minWidth; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? minHeight; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? maxWidth; + + /// Optional size constraint. If a constraint is not set the size will adjust to the content. + final double? maxHeight; + + /// Padding around the popover content. + final EdgeInsets? contentPadding; + + /// The distance from the tip of the popover arrow (tail) to the target widget. + final double? distanceToTarget; + + /// The width of the popover border. + final double borderWidth; + + /// The border radius of the popover. + final BorderRadius? borderRadius; + + /// The margin around popover. Used to prevent the popover from touching the edges of the viewport. + final double popoverMargin; + + /// The color of the popover border. + final Color borderColor; + + /// The color of the popover background. + final Color? backgroundColor; + + /// List of popover shadows. + final List? popoverShadows; + + /// Popover transition duration (fade in or out animation). + final Duration? transitionDuration; + + /// Popover transition curve (fade in or out animation). + final Curve? transitionCurve; + + /// `RouteObserver` used to listen for route changes that will hide the popover when the widget's route is not active. + final RouteObserver>? routeObserver; + + /// The widget that its placed inside the popover and functions as its content. + final Widget content; + + /// The [child] widget which the popover will target. + final Widget child; + + const MoonPopover({ + super.key, + required this.show, + this.popoverPosition = MoonPopoverPosition.top, + this.minWidth, + this.maxWidth, + this.minHeight, + this.maxHeight, + this.contentPadding, + this.distanceToTarget, + this.borderRadius, + this.borderWidth = 0, + this.popoverMargin = 8, + this.borderColor = Colors.transparent, + this.backgroundColor, + this.transitionDuration, + this.transitionCurve, + this.popoverShadows, + this.routeObserver, + required this.content, + required this.child, + }); + + // Causes any current popovers to be removed. Won't remove the supplied popover. + static void _removeOtherPopovers(MoonPopoverState current) { + if (_openedPopovers.isNotEmpty) { + // Avoid concurrent modification. + final List openedPopovers = _openedPopovers.toList(); + for (final MoonPopoverState state in openedPopovers) { + if (state == current) { + continue; + } + state._clearOverlayEntry(); + } + } + } + + @override + MoonPopoverState createState() => MoonPopoverState(); +} + +class MoonPopoverState extends State with RouteAware, SingleTickerProviderStateMixin { + final GlobalKey _popoverKey = GlobalKey(); + final LayerLink _layerLink = LayerLink(); + + AnimationController? _animationController; + CurvedAnimation? _curvedAnimation; + + bool _routeIsShowing = true; + + OverlayEntry? _overlayEntry; + + bool get shouldShowPopover => widget.show && _routeIsShowing; + + void _showPopover() { + _overlayEntry = OverlayEntry(builder: (context) => _createOverlayContent()); + Overlay.of(context).insert(_overlayEntry!); + + MoonPopover._openedPopovers.add(this); + MoonPopover._removeOtherPopovers(this); + + _animationController!.value = 0; + _animationController!.forward(); + } + + void _updatePopover() { + _overlayEntry?.markNeedsBuild(); + } + + void _removePopover({bool immediately = false}) { + if (immediately) { + _clearOverlayEntry(); + } else { + _animationController!.value = 1; + _animationController!.reverse().then((value) => _clearOverlayEntry()); + } + } + + void _clearOverlayEntry() { + if (_overlayEntry != null) { + MoonPopover._openedPopovers.remove(this); + _overlayEntry!.remove(); + _overlayEntry = null; + } + } + + Color _getTextColor(BuildContext context, {required Color effectiveBackgroundColor}) { + if (widget.backgroundColor == null && context.moonTypography != null) { + return context.moonTypography!.colors.bodyPrimary; + } + + final backgroundLuminance = effectiveBackgroundColor.computeLuminance(); + if (backgroundLuminance > 0.5) { + return MoonColors.light.bulma; + } else { + return MoonColors.dark.bulma; + } + } + + _PopoverPositionProperties _resolvePopoverPositionParameters({ + required MoonPopoverPosition popoverPosition, + required double distanceToTarget, + required double overlayWidth, + required double popoverTargetGlobalLeft, + required double popoverTargetGlobalCenter, + required double popoverTargetGlobalRight, + }) { + switch (popoverPosition) { + case MoonPopoverPosition.top: + return _PopoverPositionProperties( + offset: Offset(0, -distanceToTarget), + targetAnchor: Alignment.topCenter, + followerAnchor: Alignment.bottomCenter, + toolTipMaxWidth: + overlayWidth - ((overlayWidth / 2 - popoverTargetGlobalCenter) * 2).abs() - widget.popoverMargin * 2, + ); + + case MoonPopoverPosition.bottom: + return _PopoverPositionProperties( + offset: Offset(0, distanceToTarget), + targetAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.topCenter, + toolTipMaxWidth: + overlayWidth - ((overlayWidth / 2 - popoverTargetGlobalCenter) * 2).abs() - widget.popoverMargin * 2, + ); + + case MoonPopoverPosition.left: + return _PopoverPositionProperties( + offset: Offset(-distanceToTarget, 0), + targetAnchor: Alignment.centerLeft, + followerAnchor: Alignment.centerRight, + toolTipMaxWidth: popoverTargetGlobalLeft - distanceToTarget - widget.popoverMargin, + ); + + case MoonPopoverPosition.right: + return _PopoverPositionProperties( + offset: Offset(distanceToTarget, 0), + targetAnchor: Alignment.centerRight, + followerAnchor: Alignment.centerLeft, + toolTipMaxWidth: overlayWidth - popoverTargetGlobalRight - distanceToTarget - widget.popoverMargin, + ); + + case MoonPopoverPosition.topLeft: + return _PopoverPositionProperties( + offset: Offset(0, -distanceToTarget), + targetAnchor: Alignment.topRight, + followerAnchor: Alignment.bottomRight, + toolTipMaxWidth: popoverTargetGlobalRight - widget.popoverMargin, + ); + + case MoonPopoverPosition.topRight: + return _PopoverPositionProperties( + offset: Offset(0, -distanceToTarget), + targetAnchor: Alignment.topLeft, + followerAnchor: Alignment.bottomLeft, + toolTipMaxWidth: overlayWidth - popoverTargetGlobalLeft - widget.popoverMargin, + ); + + case MoonPopoverPosition.bottomLeft: + return _PopoverPositionProperties( + offset: Offset(0, distanceToTarget), + targetAnchor: Alignment.bottomRight, + followerAnchor: Alignment.topRight, + toolTipMaxWidth: popoverTargetGlobalRight - widget.popoverMargin, + ); + + case MoonPopoverPosition.bottomRight: + return _PopoverPositionProperties( + offset: Offset(0, distanceToTarget), + targetAnchor: Alignment.bottomLeft, + followerAnchor: Alignment.topLeft, + toolTipMaxWidth: overlayWidth - popoverTargetGlobalLeft - widget.popoverMargin, + ); + + default: + throw AssertionError(popoverPosition); + } + } + + void _handleTap(TapDownDetails details) { + final RenderBox? tooltipRenderBox = _popoverKey.currentContext?.findRenderObject() as RenderBox?; + final RenderBox? overlayRenderBox = Overlay.of(context).context.findRenderObject() as RenderBox?; + final tooltipPosition = tooltipRenderBox?.localToGlobal(Offset.zero, ancestor: overlayRenderBox); + + if (tooltipPosition != null && !tooltipRenderBox!.size.contains(details.localPosition - tooltipPosition)) { + _removePopover(); + } + } + + @override + void didPush() { + _routeIsShowing = true; + // Route was pushed onto navigator and is now topmost route. + if (shouldShowPopover) { + _removePopover(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showPopover(); + }); + } + } + + @override + void didPushNext() { + _routeIsShowing = false; + _removePopover(); + } + + @override + Future didPopNext() async { + _routeIsShowing = true; + + if (shouldShowPopover) { + // Covering route was popped off the navigator. + _removePopover(); + await Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _showPopover(); + }); + } + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.routeObserver?.subscribe(this, ModalRoute.of(context)! as PageRoute); + }); + } + + @override + void didUpdateWidget(MoonPopover oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.routeObserver != widget.routeObserver) { + oldWidget.routeObserver?.unsubscribe(this); + widget.routeObserver?.subscribe(this, ModalRoute.of(context)! as PageRoute); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_routeIsShowing) return; + if (oldWidget.popoverPosition != widget.popoverPosition) { + _removePopover(immediately: true); + _showPopover(); + } else if (shouldShowPopover && _overlayEntry == null) { + _showPopover(); + } else if (!shouldShowPopover && _overlayEntry != null) { + _removePopover(); + } + + _updatePopover(); + }); + } + + @override + void deactivate() { + if (_overlayEntry != null) { + _removePopover(immediately: true); + } + + super.deactivate(); + } + + @override + void dispose() { + if (_overlayEntry != null) { + _removePopover(immediately: true); + } + + widget.routeObserver?.unsubscribe(this); + + super.dispose(); + } + + Widget _createOverlayContent() { + MoonPopoverPosition popoverPosition = widget.popoverPosition; + + final Color effectiveBackgroundColor = + widget.backgroundColor ?? context.moonTheme?.popover.colors.backgroundColor ?? MoonColors.light.gohan; + + final Color effectiveTextColor = _getTextColor(context, effectiveBackgroundColor: effectiveBackgroundColor); + + final double effectiveDistanceToTarget = + widget.distanceToTarget ?? context.moonTheme?.popover.properties.distanceToTarget ?? 8; + + final EdgeInsets effectiveContentPadding = + widget.contentPadding ?? context.moonTheme?.popover.properties.contentPadding ?? const EdgeInsets.all(12); + + final BorderRadius effectiveBorderRadius = + widget.borderRadius ?? context.moonTheme?.popover.properties.borderRadius ?? BorderRadius.circular(12); + + final List effectivePopoverShadows = + widget.popoverShadows ?? context.moonTheme?.popover.shadows.popoverShadows ?? MoonShadows.light.sm; + + final targetRenderBox = context.findRenderObject()! as RenderBox; + final overlayRenderBox = Overlay.of(context).context.findRenderObject()! as RenderBox; + + final popoverTargetGlobalCenter = + targetRenderBox.localToGlobal(targetRenderBox.size.center(Offset.zero), ancestor: overlayRenderBox); + + final popoverTargetGlobalLeft = + targetRenderBox.localToGlobal(targetRenderBox.size.centerLeft(Offset.zero), ancestor: overlayRenderBox); + + final popoverTargetGlobalRight = + targetRenderBox.localToGlobal(targetRenderBox.size.centerRight(Offset.zero), ancestor: overlayRenderBox); + + if (Directionality.of(context) == TextDirection.rtl) { + switch (popoverPosition) { + case MoonPopoverPosition.left: + popoverPosition = MoonPopoverPosition.right; + break; + case MoonPopoverPosition.right: + popoverPosition = MoonPopoverPosition.left; + break; + case MoonPopoverPosition.topLeft: + popoverPosition = MoonPopoverPosition.topRight; + break; + case MoonPopoverPosition.topRight: + popoverPosition = MoonPopoverPosition.topLeft; + break; + case MoonPopoverPosition.bottomLeft: + popoverPosition = MoonPopoverPosition.bottomRight; + break; + case MoonPopoverPosition.bottomRight: + popoverPosition = MoonPopoverPosition.bottomLeft; + break; + + default: + } + } else if (popoverPosition == MoonPopoverPosition.horizontal || popoverPosition == MoonPopoverPosition.vertical) { + // Compute real popoverPosition based on target position + popoverPosition = (popoverPosition == MoonPopoverPosition.vertical) + ? (popoverTargetGlobalCenter.dy < overlayRenderBox.size.center(Offset.zero).dy + ? MoonPopoverPosition.bottom + : MoonPopoverPosition.top) + : (popoverTargetGlobalCenter.dx < overlayRenderBox.size.center(Offset.zero).dx + ? MoonPopoverPosition.right + : MoonPopoverPosition.left); + } + + final popoverPositionParameters = _resolvePopoverPositionParameters( + popoverPosition: popoverPosition, + distanceToTarget: effectiveDistanceToTarget, + overlayWidth: overlayRenderBox.size.width, + popoverTargetGlobalLeft: popoverTargetGlobalLeft.dx, + popoverTargetGlobalCenter: popoverTargetGlobalCenter.dx, + popoverTargetGlobalRight: popoverTargetGlobalRight.dx, + ); + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: _handleTap, + child: UnconstrainedBox( + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: popoverPositionParameters.offset, + followerAnchor: popoverPositionParameters.followerAnchor, + targetAnchor: popoverPositionParameters.targetAnchor, + child: RepaintBoundary( + child: FadeTransition( + opacity: _curvedAnimation!, + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith(color: effectiveTextColor), + child: Container( + key: _popoverKey, + constraints: BoxConstraints(maxWidth: popoverPositionParameters.toolTipMaxWidth), + padding: effectiveContentPadding, + decoration: ShapeDecoration( + color: effectiveBackgroundColor, + shadows: effectivePopoverShadows, + shape: SmoothRectangleBorder( + borderRadius: SmoothBorderRadius.only( + topLeft: SmoothRadius( + cornerRadius: effectiveBorderRadius.topLeft.x, + cornerSmoothing: 1, + ), + topRight: SmoothRadius( + cornerRadius: effectiveBorderRadius.topRight.x, + cornerSmoothing: 1, + ), + bottomLeft: SmoothRadius( + cornerRadius: effectiveBorderRadius.bottomLeft.x, + cornerSmoothing: 1, + ), + bottomRight: SmoothRadius( + cornerRadius: effectiveBorderRadius.bottomRight.x, + cornerSmoothing: 1, + ), + ), + ), + ), + child: widget.content, + ), + ), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final Duration effectiveTransitionDuration = widget.transitionDuration ?? + context.moonTheme?.popover.properties.transitionDuration ?? + const Duration(milliseconds: 150); + + final Curve effectiveTransitionCurve = + widget.transitionCurve ?? context.moonTheme?.popover.properties.transitionCurve ?? Curves.easeInOutCubic; + + _animationController ??= AnimationController( + duration: effectiveTransitionDuration, + vsync: this, + ); + + _curvedAnimation ??= CurvedAnimation( + parent: _animationController!, + curve: effectiveTransitionCurve, + ); + + return CompositedTransformTarget( + link: _layerLink, + child: widget.child, + ); + } +} + +class _PopoverPositionProperties { + final Offset offset; + final Alignment followerAnchor; + final Alignment targetAnchor; + final double toolTipMaxWidth; + + _PopoverPositionProperties({ + required this.offset, + required this.followerAnchor, + required this.targetAnchor, + required this.toolTipMaxWidth, + }); +} diff --git a/lib/src/widgets/tooltip/tooltip.dart b/lib/src/widgets/tooltip/tooltip.dart index 2dcaa090..a79e599e 100644 --- a/lib/src/widgets/tooltip/tooltip.dart +++ b/lib/src/widgets/tooltip/tooltip.dart @@ -147,10 +147,11 @@ class MoonTooltip extends StatefulWidget { } class MoonTooltipState extends State with RouteAware, SingleTickerProviderStateMixin { - AnimationController? animationController; - CurvedAnimation? curvedAnimation; + final GlobalKey _tooltipKey = GlobalKey(); + final LayerLink _layerLink = LayerLink(); - final LayerLink layerLink = LayerLink(); + AnimationController? _animationController; + CurvedAnimation? _curvedAnimation; bool _routeIsShowing = true; @@ -165,8 +166,8 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP MoonTooltip._openedTooltips.add(this); MoonTooltip._removeOtherTooltips(this); - animationController!.value = 0; - animationController!.forward(); + _animationController!.value = 0; + _animationController!.forward(); } void _updateTooltip() { @@ -177,8 +178,8 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP if (immediately) { _clearOverlayEntry(); } else { - animationController!.value = 1; - animationController!.reverse().then((value) => _clearOverlayEntry()); + _animationController!.value = 1; + _animationController!.reverse().then((value) => _clearOverlayEntry()); } } @@ -190,10 +191,16 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP } } - void _handleTap() { - if (widget.hideOnTooltipTap) { + void _handleTap(TapDownDetails details) { + final RenderBox? tooltipRenderBox = _tooltipKey.currentContext?.findRenderObject() as RenderBox?; + final RenderBox? overlayRenderBox = Overlay.of(context).context.findRenderObject() as RenderBox?; + final tooltipPosition = tooltipRenderBox?.localToGlobal(Offset.zero, ancestor: overlayRenderBox); + + if (widget.hideOnTooltipTap || + tooltipPosition != null && !tooltipRenderBox!.size.contains(details.localPosition - tooltipPosition)) { _removeTooltip(); } + widget.onTooltipTap?.call(); } @@ -225,7 +232,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(0, -(arrowTipDistance + arrowLength)), targetAnchor: Alignment.topCenter, followerAnchor: Alignment.bottomCenter, - toolTipMaxWidth: + tooltipMaxWidth: overlayWidth - ((overlayWidth / 2 - tooltipTargetGlobalCenter) * 2).abs() - widget.tooltipMargin * 2, ); @@ -234,7 +241,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(0, arrowTipDistance + arrowLength), targetAnchor: Alignment.bottomCenter, followerAnchor: Alignment.topCenter, - toolTipMaxWidth: + tooltipMaxWidth: overlayWidth - ((overlayWidth / 2 - tooltipTargetGlobalCenter) * 2).abs() - widget.tooltipMargin * 2, ); @@ -243,7 +250,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(-(arrowTipDistance + arrowLength), 0), targetAnchor: Alignment.centerLeft, followerAnchor: Alignment.centerRight, - toolTipMaxWidth: tooltipTargetGlobalLeft - arrowLength - arrowTipDistance - widget.tooltipMargin, + tooltipMaxWidth: tooltipTargetGlobalLeft - arrowLength - arrowTipDistance - widget.tooltipMargin, ); case MoonTooltipPosition.right: @@ -251,7 +258,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(arrowTipDistance + arrowLength, 0), targetAnchor: Alignment.centerRight, followerAnchor: Alignment.centerLeft, - toolTipMaxWidth: + tooltipMaxWidth: overlayWidth - tooltipTargetGlobalRight - arrowLength - arrowTipDistance - widget.tooltipMargin, ); @@ -260,7 +267,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(0, -(arrowTipDistance + arrowLength)), targetAnchor: Alignment.topRight, followerAnchor: Alignment.bottomRight, - toolTipMaxWidth: tooltipTargetGlobalRight - widget.tooltipMargin, + tooltipMaxWidth: tooltipTargetGlobalRight - widget.tooltipMargin, ); case MoonTooltipPosition.topRight: @@ -268,7 +275,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(0, -(arrowTipDistance + arrowLength)), targetAnchor: Alignment.topLeft, followerAnchor: Alignment.bottomLeft, - toolTipMaxWidth: overlayWidth - tooltipTargetGlobalLeft - widget.tooltipMargin, + tooltipMaxWidth: overlayWidth - tooltipTargetGlobalLeft - widget.tooltipMargin, ); case MoonTooltipPosition.bottomLeft: @@ -276,7 +283,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(0, arrowTipDistance + arrowLength), targetAnchor: Alignment.bottomRight, followerAnchor: Alignment.topRight, - toolTipMaxWidth: tooltipTargetGlobalRight - widget.tooltipMargin, + tooltipMaxWidth: tooltipTargetGlobalRight - widget.tooltipMargin, ); case MoonTooltipPosition.bottomRight: @@ -284,7 +291,7 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP offset: Offset(0, arrowTipDistance + arrowLength), targetAnchor: Alignment.bottomLeft, followerAnchor: Alignment.topLeft, - toolTipMaxWidth: overlayWidth - tooltipTargetGlobalLeft - widget.tooltipMargin, + tooltipMaxWidth: overlayWidth - tooltipTargetGlobalLeft - widget.tooltipMargin, ); default: @@ -463,23 +470,24 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP tooltipTargetGlobalRight: tooltipTargetGlobalRight.dx, ); - return UnconstrainedBox( - child: CompositedTransformFollower( - link: layerLink, - showWhenUnlinked: false, - offset: tooltipPositionParameters.offset, - followerAnchor: tooltipPositionParameters.followerAnchor, - targetAnchor: tooltipPositionParameters.targetAnchor, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: _handleTap, + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: _handleTap, + child: UnconstrainedBox( + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: tooltipPositionParameters.offset, + followerAnchor: tooltipPositionParameters.followerAnchor, + targetAnchor: tooltipPositionParameters.targetAnchor, child: RepaintBoundary( child: FadeTransition( - opacity: curvedAnimation!, + opacity: _curvedAnimation!, child: DefaultTextStyle( style: effectiveTextStyle, child: Container( - constraints: BoxConstraints(maxWidth: tooltipPositionParameters.toolTipMaxWidth), + key: _tooltipKey, + constraints: BoxConstraints(maxWidth: tooltipPositionParameters.tooltipMaxWidth), padding: effectiveContentPadding, decoration: ShapeDecoration( color: effectiveBackgroundColor, @@ -515,18 +523,18 @@ class MoonTooltipState extends State with RouteAware, SingleTickerP final Curve effectiveTransitionCurve = widget.transitionCurve ?? context.moonTheme?.tooltip.properties.transitionCurve ?? Curves.easeInOutCubic; - animationController ??= AnimationController( + _animationController ??= AnimationController( duration: effectiveTransitionDuration, vsync: this, ); - curvedAnimation ??= CurvedAnimation( - parent: animationController!, + _curvedAnimation ??= CurvedAnimation( + parent: _animationController!, curve: effectiveTransitionCurve, ); return CompositedTransformTarget( - link: layerLink, + link: _layerLink, child: widget.child, ); } @@ -536,12 +544,12 @@ class _TooltipPositionProperties { final Offset offset; final Alignment followerAnchor; final Alignment targetAnchor; - final double toolTipMaxWidth; + final double tooltipMaxWidth; _TooltipPositionProperties({ required this.offset, required this.followerAnchor, required this.targetAnchor, - required this.toolTipMaxWidth, + required this.tooltipMaxWidth, }); }