diff --git a/.gitignore b/.gitignore index 96486fd9..959100c0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ migrate_working_dir/ .dart_tool/ .packages build/ + +.fvm/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 2aefbdf2..b33a4ff9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,7 +2,17 @@ // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", + "version": "0.3.0", + "dart.lineLength": 120, + "dart.flutterSdkPath": ".fvm/flutter_sdk", + // Remove .fvm files from search + "search.exclude": { + "**/.fvm": true + }, + // Remove from file watching + "files.watcherExclude": { + "**/.fvm": true + }, "configurations": [ { "name": "moon_flutter", diff --git a/example/lib/src/storybook/stories/button.dart b/example/lib/src/storybook/stories/button.dart index 33f1c18e..ed3c5d0c 100644 --- a/example/lib/src/storybook/stories/button.dart +++ b/example/lib/src/storybook/stories/button.dart @@ -7,7 +7,7 @@ import 'package:storybook_flutter/storybook_flutter.dart'; class ButtonStory extends Story { ButtonStory() : super( - name: "Buttons", + name: "Button", builder: (context) { final customLabelTextKnob = context.knobs.text( label: "Custom label text", diff --git a/example/lib/src/storybook/stories/tooltip.dart b/example/lib/src/storybook/stories/tooltip.dart new file mode 100644 index 00000000..a74cdae4 --- /dev/null +++ b/example/lib/src/storybook/stories/tooltip.dart @@ -0,0 +1,108 @@ +import 'package:example/src/storybook/common/options.dart'; +import 'package:example/src/storybook/common/widgets/text_divider.dart'; +import 'package:flutter/material.dart'; +import 'package:moon_design/moon_design.dart'; +import 'package:storybook_flutter/storybook_flutter.dart'; + +class TooltipStory extends Story { + TooltipStory() + : super( + name: "Tooltip", + builder: (context) { + final customLabelTextKnob = context.knobs.text( + label: "Custom label text", + initial: "Custom tooltip text", + ); + + final tooltipPositionsKnob = context.knobs.options( + label: "tooltipPosition", + description: "Tooltip position variants.", + initial: MoonTooltipPosition.top, + options: const [ + Option(label: "top", value: MoonTooltipPosition.top), + Option(label: "bottom", value: MoonTooltipPosition.bottom), + Option(label: "left", value: MoonTooltipPosition.left), + Option(label: "right", value: MoonTooltipPosition.right), + ], + ); + + final showTooltipKnob = context.knobs.boolean( + label: "show", + description: "Show 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.", + initial: true, + ); + + final colorsKnob = context.knobs.options( + label: "backgroundColor", + description: "MoonColors variants for base MoonButton.", + initial: 4, // gohan + options: colorOptions, + ); + + final color = colorTable(context)[colorsKnob]; + + /* final setRtlModeKnob = context.knobs.boolean( + label: "RTL mode", + description: "Switch between LTR and RTL modes.", + ); */ + + return Directionality( + textDirection: /* setRtlModeKnob ? TextDirection.rtl : */ TextDirection.ltr, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 64), + const TextDivider(text: "Customisable tooltip"), + const SizedBox(height: 32), + MoonTooltip( + 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("MoonButton"), + ), + ), + const SizedBox(height: 40), + const TextDivider(text: "Default tooltip"), + const SizedBox(height: 32), + MoonPrimaryButton( + showTooltip: true, + tooltipMessage: customLabelTextKnob, + onTap: () {}, + label: const Text("MoonPrimaryButton"), + ), + const SizedBox(height: 32), + MoonChip( + showTooltip: true, + tooltipMessage: customLabelTextKnob, + borderRadius: BorderRadius.circular(20), + backgroundColor: context.moonColors!.hit, + leftIcon: const Icon(MoonIcons.frame24), + label: const Text("MoonChip"), + ), + const SizedBox(height: 64), + ], + ), + ), + ); + }, + ); +} diff --git a/example/lib/src/storybook/storybook.dart b/example/lib/src/storybook/storybook.dart index 4a17e3dc..a176b92c 100644 --- a/example/lib/src/storybook/storybook.dart +++ b/example/lib/src/storybook/storybook.dart @@ -4,6 +4,7 @@ import 'package:example/src/storybook/stories/button.dart'; import 'package:example/src/storybook/stories/chip.dart'; import 'package:example/src/storybook/stories/icons.dart'; import 'package:example/src/storybook/stories/tag.dart'; +import 'package:example/src/storybook/stories/tooltip.dart'; import 'package:flutter/material.dart'; import 'package:moon_design/moon_design.dart'; @@ -64,6 +65,7 @@ class StorybookPage extends StatelessWidget { ChipStory(), IconsStory(), TagStory(), + TooltipStory(), ], ), const Align( diff --git a/lib/moon_design.dart b/lib/moon_design.dart index 778df2d9..fa340d8c 100644 --- a/lib/moon_design.dart +++ b/lib/moon_design.dart @@ -16,12 +16,15 @@ export 'package:moon_design/src/theme/opacity.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.dart'; export 'package:moon_design/src/theme/typography/text_styles.dart'; export 'package:moon_design/src/theme/typography/typography.dart'; + export 'package:moon_design/src/utils/animated_icon_theme.dart'; export 'package:moon_design/src/utils/extensions.dart'; export 'package:moon_design/src/utils/measure_size.dart'; export 'package:moon_design/src/utils/widget_surveyor.dart'; + export 'package:moon_design/src/widgets/avatar/avatar.dart'; export 'package:moon_design/src/widgets/base_control.dart'; export 'package:moon_design/src/widgets/buttons/button.dart'; @@ -35,3 +38,4 @@ export 'package:moon_design/src/widgets/effects/focus_effect.dart'; export 'package:moon_design/src/widgets/effects/pulse_effect.dart'; export 'package:moon_design/src/widgets/icons.dart'; export 'package:moon_design/src/widgets/tag/tag.dart'; +export 'package:moon_design/src/widgets/tooltip/tooltip.dart'; diff --git a/lib/src/theme/effects/controls_effects.dart b/lib/src/theme/effects/controls_effects.dart index 89f4063e..70ce4d4a 100644 --- a/lib/src/theme/effects/controls_effects.dart +++ b/lib/src/theme/effects/controls_effects.dart @@ -7,24 +7,24 @@ import 'package:moon_design/src/theme/colors.dart'; @immutable class MoonControlsEffects extends ThemeExtension with DiagnosticableTreeMixin { static const controlScaleEffect = MoonControlsEffects( - effectCurve: Curves.easeInOutCubic, effectDuration: Duration(milliseconds: 150), + effectCurve: Curves.easeInOutCubic, effectScalar: 0.95, ); static final controlPulseEffect = MoonControlsEffects( - effectCurve: Curves.easeInOutCubic, effectDuration: const Duration(milliseconds: 1400), + effectCurve: Curves.easeInOutCubic, effectColor: MoonColors.light.piccolo, effectExtent: 24, ); - /// Controls effect curve. - final Curve effectCurve; - /// Controls effect duration. final Duration effectDuration; + /// Controls effect curve. + final Curve effectCurve; + /// Controls effect color. final Color? effectColor; @@ -35,8 +35,8 @@ class MoonControlsEffects extends ThemeExtension with Diagn final double? effectScalar; const MoonControlsEffects({ - required this.effectCurve, required this.effectDuration, + required this.effectCurve, this.effectColor, this.effectExtent, this.effectScalar, @@ -44,15 +44,15 @@ class MoonControlsEffects extends ThemeExtension with Diagn @override MoonControlsEffects copyWith({ - Curve? effectCurve, Duration? effectDuration, + Curve? effectCurve, Color? effectColor, double? effectExtent, double? effectScalar, }) { return MoonControlsEffects( - effectCurve: effectCurve ?? this.effectCurve, effectDuration: effectDuration ?? this.effectDuration, + effectCurve: effectCurve ?? this.effectCurve, effectColor: effectColor ?? this.effectColor, effectExtent: effectExtent ?? this.effectExtent, effectScalar: effectScalar ?? this.effectScalar, @@ -64,8 +64,8 @@ class MoonControlsEffects extends ThemeExtension with Diagn if (other is! MoonControlsEffects) return this; return MoonControlsEffects( - effectCurve: other.effectCurve, effectDuration: lerpDuration(effectDuration, other.effectDuration, t), + effectCurve: other.effectCurve, effectColor: Color.lerp(effectColor, other.effectColor, t), effectExtent: lerpDouble(effectExtent, other.effectExtent, t), effectScalar: lerpDouble(effectScalar, other.effectScalar, t), @@ -77,8 +77,8 @@ class MoonControlsEffects extends ThemeExtension with Diagn super.debugFillProperties(properties); properties ..add(DiagnosticsProperty("type", "MoonControlsEffects")) - ..add(DiagnosticsProperty("effectCurve", effectCurve)) ..add(DiagnosticsProperty("effectDuration", effectDuration)) + ..add(DiagnosticsProperty("effectCurve", effectCurve)) ..add(ColorProperty("effectColor", effectColor)) ..add(DoubleProperty("effectExtent", effectExtent)) ..add(DoubleProperty("transitionLowerBound", effectScalar)); diff --git a/lib/src/theme/effects/focus_effects.dart b/lib/src/theme/effects/focus_effects.dart index 02ae1803..beab64f8 100644 --- a/lib/src/theme/effects/focus_effects.dart +++ b/lib/src/theme/effects/focus_effects.dart @@ -8,15 +8,15 @@ class MoonFocusEffects extends ThemeExtension with Diagnostica static const lightFocusEffect = MoonFocusEffects( effectColor: Colors.black26, effectExtent: 4, - effectCurve: Curves.easeInOut, effectDuration: Duration(milliseconds: 150), + effectCurve: Curves.easeInOutCubic, ); static const darkFocusEffect = MoonFocusEffects( effectColor: Colors.white24, effectExtent: 4, - effectCurve: Curves.easeInOut, effectDuration: Duration(milliseconds: 150), + effectCurve: Curves.easeInOutCubic, ); /// Focus effect color. @@ -25,31 +25,31 @@ class MoonFocusEffects extends ThemeExtension with Diagnostica /// Focus effect extent. final double effectExtent; - /// Focus effect curve. - final Curve effectCurve; - /// Focus effect duration. final Duration effectDuration; + /// Focus effect curve. + final Curve effectCurve; + const MoonFocusEffects({ required this.effectColor, required this.effectExtent, - required this.effectCurve, required this.effectDuration, + required this.effectCurve, }); @override MoonFocusEffects copyWith({ Color? effectColor, double? effectExtent, - Curve? effectCurve, Duration? effectDuration, + Curve? effectCurve, }) { return MoonFocusEffects( effectColor: effectColor ?? this.effectColor, effectExtent: effectExtent ?? this.effectExtent, - effectCurve: effectCurve ?? this.effectCurve, effectDuration: effectDuration ?? this.effectDuration, + effectCurve: effectCurve ?? this.effectCurve, ); } @@ -60,8 +60,8 @@ class MoonFocusEffects extends ThemeExtension with Diagnostica return MoonFocusEffects( effectColor: Color.lerp(effectColor, other.effectColor, t)!, effectExtent: lerpDouble(effectExtent, other.effectExtent, t)!, - effectCurve: other.effectCurve, effectDuration: lerpDuration(effectDuration, other.effectDuration, t), + effectCurve: other.effectCurve, ); } @@ -72,7 +72,7 @@ class MoonFocusEffects extends ThemeExtension with Diagnostica ..add(DiagnosticsProperty("type", "MoonFocusEffects")) ..add(ColorProperty("effectColor", effectColor)) ..add(DoubleProperty("effectExtent", effectExtent)) - ..add(DiagnosticsProperty("effectCurve", effectCurve)) - ..add(DiagnosticsProperty("effectDuration", effectDuration)); + ..add(DiagnosticsProperty("effectDuration", effectDuration)) + ..add(DiagnosticsProperty("effectCurve", effectCurve)); } } diff --git a/lib/src/theme/effects/hover_effects.dart b/lib/src/theme/effects/hover_effects.dart index 691dab24..456578cb 100644 --- a/lib/src/theme/effects/hover_effects.dart +++ b/lib/src/theme/effects/hover_effects.dart @@ -8,15 +8,15 @@ class MoonHoverEffects extends ThemeExtension with Diagnostica static final lightHoverEffect = MoonHoverEffects( primaryHoverColor: MoonColors.light.heles, secondaryHoverColor: MoonColors.light.jiren, - hoverCurve: Curves.easeInOut, hoverDuration: const Duration(milliseconds: 150), + hoverCurve: Curves.easeInOutCubic, ); static final darkHoverEffect = MoonHoverEffects( primaryHoverColor: MoonColors.dark.heles, secondaryHoverColor: MoonColors.dark.jiren, - hoverCurve: Curves.easeInOut, hoverDuration: const Duration(milliseconds: 150), + hoverCurve: Curves.easeInOutCubic, ); /// Primary hover effect color. @@ -25,31 +25,31 @@ class MoonHoverEffects extends ThemeExtension with Diagnostica /// Secondary hover effect color. final Color secondaryHoverColor; - /// Hover effect curve. - final Curve hoverCurve; - /// Hover effect duration. final Duration hoverDuration; + /// Hover effect curve. + final Curve hoverCurve; + const MoonHoverEffects({ required this.primaryHoverColor, required this.secondaryHoverColor, - required this.hoverCurve, required this.hoverDuration, + required this.hoverCurve, }); @override MoonHoverEffects copyWith({ Color? primaryHoverColor, Color? secondaryHoverColor, - Curve? hoverCurve, Duration? hoverDuration, + Curve? hoverCurve, }) { return MoonHoverEffects( primaryHoverColor: primaryHoverColor ?? this.primaryHoverColor, secondaryHoverColor: secondaryHoverColor ?? this.secondaryHoverColor, - hoverCurve: hoverCurve ?? this.hoverCurve, hoverDuration: hoverDuration ?? this.hoverDuration, + hoverCurve: hoverCurve ?? this.hoverCurve, ); } @@ -60,8 +60,8 @@ class MoonHoverEffects extends ThemeExtension with Diagnostica return MoonHoverEffects( primaryHoverColor: Color.lerp(primaryHoverColor, other.primaryHoverColor, t)!, secondaryHoverColor: Color.lerp(secondaryHoverColor, other.secondaryHoverColor, t)!, - hoverCurve: other.hoverCurve, hoverDuration: lerpDuration(hoverDuration, other.hoverDuration, t), + hoverCurve: other.hoverCurve, ); } @@ -72,7 +72,7 @@ class MoonHoverEffects extends ThemeExtension with Diagnostica ..add(DiagnosticsProperty("type", "MoonHoverEffects")) ..add(ColorProperty("primaryHoverColor", primaryHoverColor)) ..add(ColorProperty("secondaryHoverColor", secondaryHoverColor)) - ..add(DiagnosticsProperty("hoverCurve", hoverCurve)) - ..add(DiagnosticsProperty("hoverDuration", hoverDuration)); + ..add(DiagnosticsProperty("hoverDuration", hoverDuration)) + ..add(DiagnosticsProperty("hoverCurve", hoverCurve)); } } diff --git a/lib/src/theme/theme.dart b/lib/src/theme/theme.dart index 1f3c412e..fd0a9d7a 100644 --- a/lib/src/theme/theme.dart +++ b/lib/src/theme/theme.dart @@ -11,6 +11,7 @@ import 'package:moon_design/src/theme/opacity.dart'; import 'package:moon_design/src/theme/shadows.dart'; import 'package:moon_design/src/theme/sizes.dart'; import 'package:moon_design/src/theme/tag/tag_theme.dart'; +import 'package:moon_design/src/theme/tooltip/tooltip.dart'; import 'package:moon_design/src/theme/typography/typography.dart'; @immutable @@ -26,6 +27,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { shadows: MoonShadows.light, sizes: MoonSizes.sizes, tagTheme: MoonTagTheme.sizes, + tooltipTheme: MoonTooltipTheme.tooltip, typography: MoonTypography.textStyles, ); @@ -40,6 +42,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { shadows: MoonShadows.dark, sizes: MoonSizes.sizes, tagTheme: MoonTagTheme.sizes, + tooltipTheme: MoonTooltipTheme.tooltip, typography: MoonTypography.textStyles, ); @@ -73,6 +76,9 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { /// Moon Design System tag theming. final MoonTagTheme tagTheme; + /// Moon Design System tooltip theming. + final MoonTooltipTheme tooltipTheme; + /// Moon Design System typography. final MoonTypography typography; @@ -87,6 +93,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { required this.shadows, required this.sizes, required this.tagTheme, + required this.tooltipTheme, required this.typography, }); @@ -102,6 +109,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { MoonShadows? shadows, MoonSizes? sizes, MoonTagTheme? tagTheme, + MoonTooltipTheme? tooltipTheme, MoonTypography? typography, }) { return MoonTheme( @@ -115,6 +123,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { shadows: shadows ?? this.shadows, sizes: sizes ?? this.sizes, tagTheme: tagTheme ?? this.tagTheme, + tooltipTheme: tooltipTheme ?? this.tooltipTheme, typography: typography ?? this.typography, ); } @@ -134,6 +143,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { shadows: shadows.lerp(other.shadows, t), sizes: sizes.lerp(other.sizes, t), tagTheme: tagTheme.lerp(other.tagTheme, t), + tooltipTheme: tooltipTheme.lerp(other.tooltipTheme, t), typography: typography.lerp(other.typography, t), ); } @@ -153,6 +163,7 @@ class MoonTheme extends ThemeExtension with DiagnosticableTreeMixin { ..add(DiagnosticsProperty("MoonShadows", shadows)) ..add(DiagnosticsProperty("MoonSizes", sizes)) ..add(DiagnosticsProperty("MoonTagTheme", tagTheme)) + ..add(DiagnosticsProperty("MoonTooltipTheme", tooltipTheme)) ..add(DiagnosticsProperty("MoonTypography", typography)); } } @@ -169,5 +180,6 @@ extension MoonThemeX on BuildContext { MoonShadows? get moonShadows => moonTheme?.shadows; MoonSizes? get moonSizes => moonTheme?.sizes; MoonTagTheme? get moonTagTheme => moonTheme?.tagTheme; + MoonTooltipTheme? get moonTooltipTheme => moonTheme?.tooltipTheme; MoonTypography? get moonTypography => moonTheme?.typography; } diff --git a/lib/src/theme/tooltip/tooltip.dart b/lib/src/theme/tooltip/tooltip.dart new file mode 100644 index 00000000..57ef8b86 --- /dev/null +++ b/lib/src/theme/tooltip/tooltip.dart @@ -0,0 +1,111 @@ +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'; +import 'package:moon_design/src/theme/typography/text_styles.dart'; + +@immutable +class MoonTooltipTheme extends ThemeExtension with DiagnosticableTreeMixin { + static final tooltip = MoonTooltipTheme( + arrowBaseWidth: MoonSizes.sizes.x2s, + arrowLength: MoonSizes.sizes.x4s, + arrowTipDistance: MoonSizes.sizes.x4s, + contentPadding: EdgeInsets.all(MoonSizes.sizes.x3s), + borderRadius: MoonBorders.borders.interactiveXs, + transitionDuration: const Duration(milliseconds: 150), + transitionCurve: Curves.easeInOutCubic, + textStyle: MoonTextStyles.text.text12, + ); + + /// The tooltip arrows base width. + final double arrowBaseWidth; + + /// The length of the tooltip arrow. + final double arrowLength; + + /// The distance between the tooltip arrow and the widget it is attached to. + final double arrowTipDistance; + + /// Padding around tooltip content. + final EdgeInsets contentPadding; + + /// Tooltip border radius. + final BorderRadius borderRadius; + + /// Tooltip transition duration (fade in or out animation). + final Duration transitionDuration; + + /// Tooltip transition curve (fade in or out animation). + final Curve transitionCurve; + + /// Tooltip text style. + final TextStyle textStyle; + + const MoonTooltipTheme({ + required this.arrowBaseWidth, + required this.arrowLength, + required this.arrowTipDistance, + required this.contentPadding, + required this.borderRadius, + required this.transitionDuration, + required this.transitionCurve, + required this.textStyle, + }); + + @override + MoonTooltipTheme copyWith({ + double? arrowBaseWidth, + double? arrowLength, + double? arrowTipDistance, + EdgeInsets? contentPadding, + BorderRadius? borderRadius, + Duration? transitionDuration, + Curve? transitionCurve, + TextStyle? textStyle, + }) { + return MoonTooltipTheme( + arrowBaseWidth: arrowBaseWidth ?? this.arrowBaseWidth, + arrowLength: arrowLength ?? this.arrowLength, + arrowTipDistance: arrowTipDistance ?? this.arrowTipDistance, + contentPadding: contentPadding ?? this.contentPadding, + borderRadius: borderRadius ?? this.borderRadius, + transitionDuration: transitionDuration ?? this.transitionDuration, + transitionCurve: transitionCurve ?? this.transitionCurve, + textStyle: textStyle ?? this.textStyle, + ); + } + + @override + MoonTooltipTheme lerp(ThemeExtension? other, double t) { + if (other is! MoonTooltipTheme) return this; + + return MoonTooltipTheme( + arrowBaseWidth: lerpDouble(arrowBaseWidth, other.arrowBaseWidth, t)!, + arrowLength: lerpDouble(arrowLength, other.arrowLength, t)!, + arrowTipDistance: lerpDouble(arrowTipDistance, other.arrowTipDistance, t)!, + contentPadding: EdgeInsets.lerp(contentPadding, other.contentPadding, t)!, + borderRadius: BorderRadius.lerp(borderRadius, other.borderRadius, t)!, + transitionDuration: lerpDuration(transitionDuration, other.transitionDuration, t), + transitionCurve: other.transitionCurve, + textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty("type", "MoonTooltipTheme")) + ..add(DoubleProperty("arrowBaseWidth", arrowBaseWidth)) + ..add(DoubleProperty("arrowLength", arrowLength)) + ..add(DoubleProperty("arrowTipDistance", arrowTipDistance)) + ..add(DiagnosticsProperty("contentPadding", contentPadding)) + ..add(DiagnosticsProperty("borderRadius", borderRadius)) + ..add(DiagnosticsProperty("transitionDuration", transitionDuration)) + ..add(DiagnosticsProperty("transitionCurve", transitionCurve)) + ..add(DiagnosticsProperty("textStyle", textStyle)); + } +} diff --git a/lib/src/widgets/avatar/avatar_clipper.dart b/lib/src/widgets/avatar/avatar_clipper.dart index e679f673..9b237631 100644 --- a/lib/src/widgets/avatar/avatar_clipper.dart +++ b/lib/src/widgets/avatar/avatar_clipper.dart @@ -132,10 +132,7 @@ class AvatarClipper extends CustomClipper { 0, width, height, - SmoothRadius( - cornerRadius: min(borderRadiusValue, smallestDimension / 2), - cornerSmoothing: 1, - ), + SmoothRadius(cornerRadius: min(borderRadiusValue, smallestDimension / 2), cornerSmoothing: 1), ), ), // Badge shape properties diff --git a/lib/src/widgets/base_control.dart b/lib/src/widgets/base_control.dart index e9a44789..f951846b 100644 --- a/lib/src/widgets/base_control.dart +++ b/lib/src/widgets/base_control.dart @@ -8,6 +8,7 @@ import 'package:moon_design/src/utils/extensions.dart'; import 'package:moon_design/src/utils/touch_target_padding.dart'; import 'package:moon_design/src/widgets/effects/focus_effect.dart'; import 'package:moon_design/src/widgets/effects/pulse_effect.dart'; +import 'package:moon_design/src/widgets/tooltip/tooltip.dart'; typedef MoonBaseControlBuilder = Widget Function( BuildContext context, @@ -36,6 +37,9 @@ class MoonBaseControl extends StatefulWidget { /// Whether this control should ensure that it has a minimal touch target size. final bool ensureMinimalTouchTargetSize; + /// Whether this control should show a tooltip. + final bool showTooltip; + /// Whether this control should show a focus effect. final bool showFocusEffect; @@ -54,6 +58,9 @@ class MoonBaseControl extends StatefulWidget { /// The semantic label for this control. final String? semanticLabel; + /// The tooltip message for this control. + final String tooltipMessage; + /// The minimum size of the touch target. final double minTouchTargetSize; @@ -114,12 +121,14 @@ class MoonBaseControl extends StatefulWidget { this.autofocus = false, this.isFocusable = true, this.ensureMinimalTouchTargetSize = false, + this.showTooltip = false, this.showFocusEffect = true, this.showPulseEffect = false, this.showPulseEffectJiggle = true, this.showScaleAnimation = true, this.semanticTypeIsButton = false, this.semanticLabel, + this.tooltipMessage = "", this.minTouchTargetSize = 40.0, this.disabledOpacityValue, this.focusEffectExtent, @@ -147,15 +156,17 @@ class _MoonBaseControlState extends State { bool _isFocused = false; bool _isHovered = false; bool _isPressed = false; + bool _isLongPressed = false; FocusNode? _focusNode; late Map> _actions; bool get _isEnabled => widget.onTap != null || widget.onLongPress != null; + bool get _canShowTooltip => widget.showTooltip && _isEnabled && (_isFocused || _isHovered || _isLongPressed); bool get _canAnimateFocus => widget.showFocusEffect && _isEnabled && _isFocused; bool get _canAnimatePulse => widget.showPulseEffect && _isEnabled; - bool get _canAnimateScale => widget.showScaleAnimation && _isEnabled && _isPressed; + bool get _canAnimateScale => widget.showScaleAnimation && _isEnabled && (_isPressed || _isLongPressed); MouseCursor get _cursor => _isEnabled ? widget.cursor : SystemMouseCursors.forbidden; FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode()); @@ -196,6 +207,12 @@ class _MoonBaseControlState extends State { } } + void _handleTapDown(_) { + if (!_isPressed && mounted) { + setState(() => _isPressed = true); + } + } + void _handleTapUp(_) { if (_isPressed && mounted) { setState(() => _isPressed = false); @@ -208,23 +225,31 @@ class _MoonBaseControlState extends State { } } - void _handleLongPressDown(_) { + void _handleLongPressStart(_) { + if (!_isLongPressed && mounted) { + setState(() => _isLongPressed = true); + } + if (!_isPressed && mounted) { setState(() => _isPressed = true); } } void _handleLongPressUp() { + if (_isLongPressed && mounted) { + setState(() => _isLongPressed = false); + } + if (_isPressed && mounted) { setState(() => _isPressed = false); } } - void _handleHorizontalDragStart(DragStartDetails dragStartDetails) => _handleLongPressDown(null); + void _handleHorizontalDragStart(DragStartDetails dragStartDetails) => _handleTapDown(null); void _handleHorizontalDragEnd(DragEndDetails dragEndDetails) => _handleTapUp(null); - void _handleVerticalDragStart(DragStartDetails dragStartDetails) => _handleLongPressDown(null); + void _handleVerticalDragStart(DragStartDetails dragStartDetails) => _handleTapDown(null); void _handleVerticalDragEnd(DragEndDetails dragEndDetails) => _handleTapUp(null); @@ -262,9 +287,9 @@ class _MoonBaseControlState extends State { _effectiveFocusNode.canRequestFocus = _isEnabled; - if (_isPressed && mounted) { + /* if (_isPressed && mounted) { setState(() => _isPressed = false); - } + } */ } @override @@ -336,61 +361,67 @@ class _MoonBaseControlState extends State { _isPressed, ); - return RepaintBoundary( - child: MergeSemantics( - child: Semantics( - label: widget.semanticLabel, - button: widget.semanticTypeIsButton, - enabled: _isEnabled, - focusable: _isEnabled, - focused: _isFocused, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: _handleTap, - onLongPress: _handleLongPress, - onLongPressDown: _handleLongPressDown, - onLongPressUp: _handleLongPressUp, - onHorizontalDragStart: _handleHorizontalDragStart, - onHorizontalDragEnd: _handleHorizontalDragEnd, - onVerticalDragStart: _handleVerticalDragStart, - onVerticalDragEnd: _handleVerticalDragEnd, - child: FocusableActionDetector( - enabled: _isEnabled && widget.isFocusable, - focusNode: _effectiveFocusNode, - autofocus: _isEnabled && widget.autofocus, - mouseCursor: _cursor, - onShowHoverHighlight: _handleHover, - onShowFocusHighlight: _handleFocus, - onFocusChange: _handleFocusChange, - actions: _actions, - child: TouchTargetPadding( - minSize: widget.ensureMinimalTouchTargetSize - ? Size(widget.minTouchTargetSize, widget.minTouchTargetSize) - : Size.zero, - child: AnimatedScale( - scale: _canAnimateScale ? effectiveScaleEffectScalar : 1, - duration: effectiveScaleEffectDuration, - curve: effectiveScaleEffectCurve, - child: MoonPulseEffect( - show: _canAnimatePulse, - showJiggle: widget.showPulseEffectJiggle, - childBorderRadius: widget.borderRadius, - effectColor: effectivePulseEffectColor, - effectExtent: effectivePulseEffectExtent, - effectCurve: effectivePulseEffectCurve, - effectDuration: effectivePulseEffectDuration, - child: AnimatedOpacity( - opacity: _isEnabled ? 1 : effectiveDisabledOpacityValue, - duration: const Duration(milliseconds: 150), - curve: Curves.easeInOut, - child: MoonFocusEffect( - show: _canAnimateFocus, - effectColor: focusColor, - effectExtent: effectiveFocusEffectExtent, - effectCurve: effectiveFocusEffectCurve, - effectDuration: effectiveFocusEffectDuration, - childBorderRadius: widget.borderRadius, - child: child, + return MoonTooltip( + show: _canShowTooltip, + content: Text(widget.tooltipMessage), + child: RepaintBoundary( + child: MergeSemantics( + child: Semantics( + label: widget.semanticLabel, + button: widget.semanticTypeIsButton, + enabled: _isEnabled, + focusable: _isEnabled, + focused: _isFocused, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleTap, + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onLongPress: _handleLongPress, + onLongPressStart: _handleLongPressStart, + onLongPressUp: _handleLongPressUp, + onHorizontalDragStart: _handleHorizontalDragStart, + onHorizontalDragEnd: _handleHorizontalDragEnd, + onVerticalDragStart: _handleVerticalDragStart, + onVerticalDragEnd: _handleVerticalDragEnd, + child: FocusableActionDetector( + enabled: _isEnabled && widget.isFocusable, + focusNode: _effectiveFocusNode, + autofocus: _isEnabled && widget.autofocus, + mouseCursor: _cursor, + onShowHoverHighlight: _handleHover, + onShowFocusHighlight: _handleFocus, + onFocusChange: _handleFocusChange, + actions: _actions, + child: TouchTargetPadding( + minSize: widget.ensureMinimalTouchTargetSize + ? Size(widget.minTouchTargetSize, widget.minTouchTargetSize) + : Size.zero, + child: AnimatedScale( + scale: _canAnimateScale ? effectiveScaleEffectScalar : 1, + duration: effectiveScaleEffectDuration, + curve: effectiveScaleEffectCurve, + child: MoonPulseEffect( + show: _canAnimatePulse, + showJiggle: widget.showPulseEffectJiggle, + childBorderRadius: widget.borderRadius, + effectColor: effectivePulseEffectColor, + effectExtent: effectivePulseEffectExtent, + effectCurve: effectivePulseEffectCurve, + effectDuration: effectivePulseEffectDuration, + child: AnimatedOpacity( + opacity: _isEnabled ? 1 : effectiveDisabledOpacityValue, + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOutCubic, + child: MoonFocusEffect( + show: _canAnimateFocus, + effectColor: focusColor, + effectExtent: effectiveFocusEffectExtent, + effectCurve: effectiveFocusEffectCurve, + effectDuration: effectiveFocusEffectDuration, + childBorderRadius: widget.borderRadius, + child: child, + ), ), ), ), diff --git a/lib/src/widgets/buttons/button.dart b/lib/src/widgets/buttons/button.dart index 445d04b3..61fbc9c5 100644 --- a/lib/src/widgets/buttons/button.dart +++ b/lib/src/widgets/buttons/button.dart @@ -34,6 +34,9 @@ class MoonButton extends StatelessWidget { /// The semantic label for the button. final String? semanticLabel; + /// The tooltip message for the button. + final String tooltipMessage; + /// The width of the button. final double? width; @@ -76,6 +79,9 @@ class MoonButton extends StatelessWidget { /// Whether the button should show a border. final bool showBorder; + /// Whether the button should show a tooltip. + final bool showTooltip; + /// Whether the button should show a focus effect. final bool showFocusEffect; @@ -160,6 +166,7 @@ class MoonButton extends StatelessWidget { this.buttonSize, this.focusNode, this.semanticLabel, + this.tooltipMessage = "", this.width, this.height, this.disabledOpacityValue, @@ -174,6 +181,7 @@ class MoonButton extends StatelessWidget { this.isFullWidth = false, this.ensureMinimalTouchTargetSize = false, this.showBorder = false, + this.showTooltip = false, this.showFocusEffect = true, this.showPulseEffect = false, this.showPulseEffectJiggle = true, @@ -206,6 +214,7 @@ class MoonButton extends StatelessWidget { MoonButtonSize? buttonSize, FocusNode? focusNode, String? semanticLabel, + String tooltipMessage = "", double? width, double? height, double? disabledOpacityValue, @@ -219,6 +228,7 @@ class MoonButton extends StatelessWidget { bool isFocusable = true, bool ensureMinimalTouchTargetSize = false, bool showBorder = false, + bool showTooltip = false, bool showFocusEffect = true, bool showPulseEffect = false, bool showPulseEffectJiggle = true, @@ -247,6 +257,7 @@ class MoonButton extends StatelessWidget { buttonSize: buttonSize, focusNode: focusNode, semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, width: width, height: height, disabledOpacityValue: disabledOpacityValue, @@ -260,6 +271,7 @@ class MoonButton extends StatelessWidget { isFocusable: isFocusable, ensureMinimalTouchTargetSize: ensureMinimalTouchTargetSize, showBorder: showBorder, + showTooltip: showTooltip, showFocusEffect: showFocusEffect, showPulseEffect: showPulseEffect, showPulseEffectJiggle: showPulseEffectJiggle, @@ -355,6 +367,7 @@ class MoonButton extends StatelessWidget { onTap: onTap, onLongPress: onLongPress, semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, semanticTypeIsButton: true, borderRadius: effectiveBorderRadius, disabledOpacityValue: disabledOpacityValue, @@ -363,8 +376,9 @@ class MoonButton extends StatelessWidget { focusNode: focusNode, autofocus: autofocus, isFocusable: isFocusable, - showFocusEffect: showFocusEffect, backgroundColor: backgroundColor, + showTooltip: showTooltip, + showFocusEffect: showFocusEffect, focusEffectColor: focusEffectColor, focusEffectExtent: focusEffectExtent, focusEffectDuration: focusEffectDuration, diff --git a/lib/src/widgets/buttons/ghost_button.dart b/lib/src/widgets/buttons/ghost_button.dart index 1f374fa7..18a457c2 100644 --- a/lib/src/widgets/buttons/ghost_button.dart +++ b/lib/src/widgets/buttons/ghost_button.dart @@ -21,6 +21,9 @@ class MoonGhostButton extends StatelessWidget { /// The semantic label for the button. final String? semanticLabel; + /// The tooltip message for the button. + final String tooltipMessage; + /// The width of the button. final double? width; @@ -42,6 +45,9 @@ class MoonGhostButton extends StatelessWidget { /// Whether this button should be full width. final bool isFullWidth; + /// Whether this button should show a tooltip. + final bool showTooltip; + /// Whether this button should show a pulse effect. final bool showPulseEffect; @@ -68,6 +74,7 @@ class MoonGhostButton extends StatelessWidget { this.buttonSize, this.focusNode, this.semanticLabel, + this.tooltipMessage = "", this.width, this.height, this.minTouchTargetSize = 40, @@ -75,6 +82,7 @@ class MoonGhostButton extends StatelessWidget { this.autofocus = false, this.isFocusable = true, this.isFullWidth = false, + this.showTooltip = false, this.showPulseEffect = false, this.label, this.leftIcon, @@ -94,6 +102,7 @@ class MoonGhostButton extends StatelessWidget { buttonSize: buttonSize, focusNode: focusNode, semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, width: width, height: height, minTouchTargetSize: minTouchTargetSize, @@ -102,6 +111,7 @@ class MoonGhostButton extends StatelessWidget { isFocusable: isFocusable, isFullWidth: isFullWidth, textColor: effectiveTextColor, + showTooltip: showTooltip, showPulseEffect: showPulseEffect, hoverEffectColor: effectiveHoverColor, focusEffectColor: effectiveFocusColor, diff --git a/lib/src/widgets/buttons/primary_button.dart b/lib/src/widgets/buttons/primary_button.dart index 8ea7f186..ee78326f 100644 --- a/lib/src/widgets/buttons/primary_button.dart +++ b/lib/src/widgets/buttons/primary_button.dart @@ -27,6 +27,9 @@ class MoonPrimaryButton extends StatelessWidget { /// The semantic label for the button. final String? semanticLabel; + /// The tooltip message for the button. + final String tooltipMessage; + /// The width of the button. final double? width; @@ -48,6 +51,9 @@ class MoonPrimaryButton extends StatelessWidget { /// Whether this button should be full width. final bool isFullWidth; + /// Whether this button should show a tooltip. + final bool showTooltip; + /// Whether this button should show a pulse effect. final bool showPulseEffect; @@ -67,6 +73,7 @@ class MoonPrimaryButton extends StatelessWidget { this.buttonSize, this.focusNode, this.semanticLabel, + this.tooltipMessage = "", this.width, this.height, this.minTouchTargetSize = 40, @@ -74,6 +81,7 @@ class MoonPrimaryButton extends StatelessWidget { this.autofocus = false, this.isFocusable = true, this.isFullWidth = false, + this.showTooltip = false, this.showPulseEffect = false, this.label, this.leftIcon, @@ -91,6 +99,7 @@ class MoonPrimaryButton extends StatelessWidget { backgroundColor: effectiveBackgroundColor, focusNode: focusNode, semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, width: width, height: height, minTouchTargetSize: minTouchTargetSize, @@ -98,6 +107,7 @@ class MoonPrimaryButton extends StatelessWidget { autofocus: autofocus, isFocusable: isFocusable, isFullWidth: isFullWidth, + showTooltip: showTooltip, showPulseEffect: showPulseEffect, label: label, leftIcon: leftIcon, diff --git a/lib/src/widgets/buttons/secondary_button.dart b/lib/src/widgets/buttons/secondary_button.dart index 5feb39e0..c782bb35 100644 --- a/lib/src/widgets/buttons/secondary_button.dart +++ b/lib/src/widgets/buttons/secondary_button.dart @@ -18,6 +18,9 @@ class MoonSecondaryButton extends StatelessWidget { /// The semantic label for the button. final String? semanticLabel; + /// The tooltip message for the button. + final String tooltipMessage; + /// The width of the button. final double? width; @@ -39,6 +42,9 @@ class MoonSecondaryButton extends StatelessWidget { /// Whether this button should be full width. final bool isFullWidth; + /// Whether the button should show a tooltip. + final bool showTooltip; + /// Whether this button should show a pulse effect. final bool showPulseEffect; @@ -65,6 +71,7 @@ class MoonSecondaryButton extends StatelessWidget { this.buttonSize, this.focusNode, this.semanticLabel, + this.tooltipMessage = "", this.width, this.height, this.minTouchTargetSize = 40, @@ -72,6 +79,7 @@ class MoonSecondaryButton extends StatelessWidget { this.autofocus = false, this.isFocusable = true, this.isFullWidth = false, + this.showTooltip = false, this.showPulseEffect = false, this.label, this.leftIcon, @@ -86,6 +94,7 @@ class MoonSecondaryButton extends StatelessWidget { buttonSize: buttonSize, focusNode: focusNode, semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, width: width, height: height, minTouchTargetSize: minTouchTargetSize, @@ -93,6 +102,7 @@ class MoonSecondaryButton extends StatelessWidget { autofocus: autofocus, isFocusable: isFocusable, isFullWidth: isFullWidth, + showTooltip: showTooltip, showPulseEffect: showPulseEffect, showBorder: true, label: label, diff --git a/lib/src/widgets/buttons/tertiary_button.dart b/lib/src/widgets/buttons/tertiary_button.dart index 803925e3..d02b4843 100644 --- a/lib/src/widgets/buttons/tertiary_button.dart +++ b/lib/src/widgets/buttons/tertiary_button.dart @@ -20,6 +20,9 @@ class MoonTertiaryButton extends StatelessWidget { /// The semantic label for the button. final String? semanticLabel; + /// The tooltip message for the button. + final String tooltipMessage; + /// The width of the button. final double? width; @@ -41,6 +44,9 @@ class MoonTertiaryButton extends StatelessWidget { /// Whether this button should be full width. final bool isFullWidth; + /// Whether the button should show a tooltip. + final bool showTooltip; + /// Whether this button should show a pulse effect. final bool showPulseEffect; @@ -67,6 +73,7 @@ class MoonTertiaryButton extends StatelessWidget { this.buttonSize, this.focusNode, this.semanticLabel, + this.tooltipMessage = "", this.width, this.height, this.minTouchTargetSize = 40, @@ -74,6 +81,7 @@ class MoonTertiaryButton extends StatelessWidget { this.autofocus = false, this.isFocusable = true, this.isFullWidth = false, + this.showTooltip = false, this.showPulseEffect = false, this.label, this.leftIcon, @@ -93,6 +101,7 @@ class MoonTertiaryButton extends StatelessWidget { borderColor: effectiveBorderColor, focusNode: focusNode, semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, width: width, height: height, minTouchTargetSize: minTouchTargetSize, @@ -100,6 +109,7 @@ class MoonTertiaryButton extends StatelessWidget { autofocus: autofocus, isFocusable: isFocusable, isFullWidth: isFullWidth, + showTooltip: showTooltip, showPulseEffect: showPulseEffect, label: label, leftIcon: leftIcon, diff --git a/lib/src/widgets/chips/chip.dart b/lib/src/widgets/chips/chip.dart index 06879710..ee94dd2c 100644 --- a/lib/src/widgets/chips/chip.dart +++ b/lib/src/widgets/chips/chip.dart @@ -30,6 +30,9 @@ class MoonChip extends StatelessWidget { /// The semantic label for the chip. final String? semanticLabel; + /// The tooltip message for the chip. + final String tooltipMessage; + /// The width of the chip. final double? width; @@ -66,6 +69,9 @@ class MoonChip extends StatelessWidget { /// Whether the chip should show a border. final bool showBorder; + /// Whether the chip should show a tooltip. + final bool showTooltip; + /// Whether the chip should show a focus effect. final bool showFocusEffect; @@ -121,6 +127,7 @@ class MoonChip extends StatelessWidget { this.chipSize, this.focusNode, this.semanticLabel, + this.tooltipMessage = "", this.width, this.height, this.disabledOpacityValue, @@ -133,6 +140,7 @@ class MoonChip extends StatelessWidget { this.isFocusable = true, this.ensureMinimalTouchTargetSize = false, this.showBorder = false, + this.showTooltip = false, this.showFocusEffect = true, this.backgroundColor, this.activeColor, @@ -223,6 +231,7 @@ class MoonChip extends StatelessWidget { onTap: onTap ?? () {}, onLongPress: onLongPress, semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, borderRadius: effectiveBorderRadius, disabledOpacityValue: disabledOpacityValue, minTouchTargetSize: minTouchTargetSize, @@ -230,6 +239,7 @@ class MoonChip extends StatelessWidget { focusNode: focusNode, autofocus: autofocus, isFocusable: isFocusable, + showTooltip: showTooltip, showFocusEffect: showFocusEffect, backgroundColor: backgroundColor, focusEffectColor: focusEffectColor, diff --git a/lib/src/widgets/chips/ghost_chip.dart b/lib/src/widgets/chips/ghost_chip.dart index a2a0cefb..e7206036 100644 --- a/lib/src/widgets/chips/ghost_chip.dart +++ b/lib/src/widgets/chips/ghost_chip.dart @@ -20,6 +20,9 @@ class MoonGhostChip extends StatelessWidget { /// The semantic label for the chip. final String? semanticLabel; + /// The tooltip message for the button. + final String tooltipMessage; + /// The width of the chip. final double? width; @@ -56,6 +59,9 @@ class MoonGhostChip extends StatelessWidget { /// Whether the chip should show a border. final bool showBorder; + /// Whether the chip should show a tooltip. + final bool showTooltip; + /// Whether the chip should show a focus effect. final bool showFocusEffect; @@ -108,6 +114,7 @@ class MoonGhostChip extends StatelessWidget { this.chipSize, this.focusNode, this.semanticLabel, + this.tooltipMessage = "", this.width, this.height, this.disabledOpacityValue, @@ -120,6 +127,7 @@ class MoonGhostChip extends StatelessWidget { this.isFocusable = true, this.ensureMinimalTouchTargetSize = false, this.showBorder = false, + this.showTooltip = false, this.showFocusEffect = true, this.activeColor, this.borderColor, @@ -149,6 +157,8 @@ class MoonGhostChip extends StatelessWidget { height: height, gap: gap, padding: padding, + semanticLabel: semanticLabel, + tooltipMessage: tooltipMessage, isActive: isActive, activeColor: activeColor, backgroundColor: Colors.transparent, @@ -156,6 +166,7 @@ class MoonGhostChip extends StatelessWidget { textColor: effectiveTextColor, chipSize: chipSize, showBorder: showBorder, + showTooltip: showTooltip, borderRadius: borderRadius, disabledOpacityValue: disabledOpacityValue, showFocusEffect: showFocusEffect, @@ -171,7 +182,6 @@ class MoonGhostChip extends StatelessWidget { hoverEffectDuration: hoverEffectDuration, ensureMinimalTouchTargetSize: ensureMinimalTouchTargetSize, minTouchTargetSize: minTouchTargetSize, - semanticLabel: semanticLabel, leftIcon: leftIcon, label: label, rightIcon: rightIcon, diff --git a/lib/src/widgets/effects/focus_effect.dart b/lib/src/widgets/effects/focus_effect.dart index b290785c..5f732c59 100644 --- a/lib/src/widgets/effects/focus_effect.dart +++ b/lib/src/widgets/effects/focus_effect.dart @@ -7,8 +7,8 @@ class MoonFocusEffect extends StatefulWidget { final bool show; final double effectExtent; final Color effectColor; - final Curve effectCurve; final Duration effectDuration; + final Curve effectCurve; final BorderRadius? childBorderRadius; final Widget child; @@ -17,8 +17,8 @@ class MoonFocusEffect extends StatefulWidget { required this.show, required this.effectExtent, required this.effectColor, - required this.effectCurve, required this.effectDuration, + required this.effectCurve, this.childBorderRadius, required this.child, }); @@ -28,26 +28,16 @@ class MoonFocusEffect extends StatefulWidget { } class _MoonFocusEffectState extends State with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late CurvedAnimation _focusAnimation; - - @override - void initState() { - super.initState(); - - if (mounted) { - _animationController = AnimationController( - vsync: this, - duration: widget.effectDuration, - debugLabel: "MoonFocusEffect animation controller", - ); + late final AnimationController _animationController = AnimationController( + vsync: this, + duration: widget.effectDuration, + debugLabel: "MoonFocusEffect animation controller", + ); - _focusAnimation = CurvedAnimation( - parent: _animationController, - curve: widget.effectCurve, - ); - } - } + late final CurvedAnimation _focusAnimation = CurvedAnimation( + parent: _animationController, + curve: widget.effectCurve, + ); @override void didUpdateWidget(MoonFocusEffect oldWidget) { diff --git a/lib/src/widgets/effects/pulse_effect.dart b/lib/src/widgets/effects/pulse_effect.dart index f58962a1..2c31157f 100644 --- a/lib/src/widgets/effects/pulse_effect.dart +++ b/lib/src/widgets/effects/pulse_effect.dart @@ -8,8 +8,8 @@ class MoonPulseEffect extends StatefulWidget { final bool showJiggle; final double effectExtent; final Color effectColor; - final Curve effectCurve; final Duration effectDuration; + final Curve effectCurve; final BorderRadius? childBorderRadius; final Widget child; @@ -19,8 +19,8 @@ class MoonPulseEffect extends StatefulWidget { required this.showJiggle, required this.effectExtent, required this.effectColor, - required this.effectCurve, required this.effectDuration, + required this.effectCurve, this.childBorderRadius, required this.child, }); @@ -33,53 +33,42 @@ class _MoonPulseEffectState extends State with SingleTickerProv static const double _jiggleTimePercentage = 28.6; static const double _jiggleRestTimePercentage = 100 - _jiggleTimePercentage * 2; - late AnimationController _animationController; - late CurvedAnimation _pulseAnimation; - late Animation _jiggleAnimation; - - @override - void initState() { - super.initState(); - - if (mounted) { - _animationController = AnimationController( - animationBehavior: AnimationBehavior.preserve, - vsync: this, - duration: widget.effectDuration, - debugLabel: "MoonPulseEffect animation controller", - ); - - _pulseAnimation = CurvedAnimation( - parent: _animationController, - curve: widget.effectCurve, - ); - - _jiggleAnimation = TweenSequence( - [ - TweenSequenceItem( - tween: Tween(begin: 0.0, end: -1.0).chain(CurveTween(curve: widget.effectCurve)), - weight: _jiggleRestTimePercentage / 2, - ), - TweenSequenceItem( - tween: Tween(begin: -1.0, end: 0.0).chain(CurveTween(curve: widget.effectCurve)), - weight: _jiggleRestTimePercentage / 2, - ), - TweenSequenceItem( - tween: ConstantTween(0.0), - weight: _jiggleRestTimePercentage, - ), - TweenSequenceItem( - tween: Tween(begin: 0.0, end: -1.0).chain(CurveTween(curve: widget.effectCurve)), - weight: _jiggleRestTimePercentage / 2, - ), - TweenSequenceItem( - tween: Tween(begin: -1.0, end: 0.0).chain(CurveTween(curve: widget.effectCurve)), - weight: _jiggleRestTimePercentage / 2, - ), - ], - ).animate(_animationController); - } - } + late final AnimationController _animationController = AnimationController( + animationBehavior: AnimationBehavior.preserve, + vsync: this, + duration: widget.effectDuration, + debugLabel: "MoonPulseEffect animation controller", + ); + + late final CurvedAnimation _pulseAnimation = CurvedAnimation( + parent: _animationController, + curve: widget.effectCurve, + ); + + late final Animation _jiggleAnimation = TweenSequence( + [ + TweenSequenceItem( + tween: Tween(begin: 0.0, end: -1.0).chain(CurveTween(curve: widget.effectCurve)), + weight: _jiggleRestTimePercentage / 2, + ), + TweenSequenceItem( + tween: Tween(begin: -1.0, end: 0.0).chain(CurveTween(curve: widget.effectCurve)), + weight: _jiggleRestTimePercentage / 2, + ), + TweenSequenceItem( + tween: ConstantTween(0.0), + weight: _jiggleRestTimePercentage, + ), + TweenSequenceItem( + tween: Tween(begin: 0.0, end: -1.0).chain(CurveTween(curve: widget.effectCurve)), + weight: _jiggleRestTimePercentage / 2, + ), + TweenSequenceItem( + tween: Tween(begin: -1.0, end: 0.0).chain(CurveTween(curve: widget.effectCurve)), + weight: _jiggleRestTimePercentage / 2, + ), + ], + ).animate(_animationController); @override void didUpdateWidget(covariant MoonPulseEffect oldWidget) { diff --git a/lib/src/widgets/tooltip/obfuscate_tooltip_item.dart b/lib/src/widgets/tooltip/obfuscate_tooltip_item.dart new file mode 100644 index 00000000..4c245263 --- /dev/null +++ b/lib/src/widgets/tooltip/obfuscate_tooltip_item.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:moon_design/src/widgets/tooltip/tooltip.dart'; + +class ObfuscateTooltipItem extends StatefulWidget { + /// This is just needed when the `ObfuscateTooltipItem` is placed outside the context + /// of `ObfuscateTooltipLayoutState`, ie: In a modal route or an OverlayLayout + final List> tooltipKeys; + + final Widget child; + + const ObfuscateTooltipItem({ + super.key, + required this.tooltipKeys, + required this.child, + }); + + @override + ObfuscateTooltipItemState createState() => ObfuscateTooltipItemState(); +} + +class ObfuscateTooltipItemState extends State with WidgetsBindingObserver { + final GlobalKey _key = GlobalKey(); + + late StreamSubscription intervalSubcription; + _PositionAndSize? _lastPositionSize; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + intervalSubcription = Stream.periodic(const Duration(seconds: 1)).listen((event) { + final currentPositionSize = getPositionAndSize(); + if (_lastPositionSize != currentPositionSize) { + _notifySizeChange(widget.tooltipKeys); + } + _lastPositionSize = currentPositionSize; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + _addToTooltips(widget.tooltipKeys); + }); + } + + @override + void didUpdateWidget(ObfuscateTooltipItem oldWidget) { + if (oldWidget.tooltipKeys != widget.tooltipKeys) { + _removeFromTooltips(oldWidget.tooltipKeys); + _addToTooltips(widget.tooltipKeys); + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + intervalSubcription.cancel(); + _removeFromTooltips(widget.tooltipKeys); + super.dispose(); + } + + @override + void didChangeMetrics() { + _notifySizeChange(widget.tooltipKeys); + } + + void _notifySizeChange(List> keys) { + if (keys.isNotEmpty) { + for (final tooltipKey in keys) { + tooltipKey.currentState?.doCheckForObfuscation(); + } + } + } + + _PositionAndSize? getPositionAndSize() { + if (!mounted) return null; + final RenderBox? renderBox = _key.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.attached) return null; + final position = renderBox.localToGlobal(Offset.zero); + return _PositionAndSize( + context: context, + globalPosition: position, + size: renderBox.size, + ); + } + + @override + Widget build(BuildContext context) { + return Container( + key: _key, + child: widget.child, + ); + } + + void _addToTooltips(List> keys) { + if (keys.isNotEmpty) { + for (final tooltipKey in keys) { + tooltipKey.currentState?.addObfuscateItem(this); + } + } + } + + void _removeFromTooltips(List> keys) { + if (keys.isNotEmpty) { + for (final tooltipKey in keys) { + tooltipKey.currentState?.removeObfuscatedItem(this); + } + } + } +} + +class _PositionAndSize { + final BuildContext context; + final Size size; + final Offset globalPosition; + _PositionAndSize({ + required this.context, + required this.size, + required this.globalPosition, + }); + + @override + bool operator ==(Object o) { + if (identical(this, o)) return true; + + return o is _PositionAndSize && o.size == size && o.globalPosition == globalPosition; + } + + @override + int get hashCode => size.hashCode ^ globalPosition.hashCode; +} diff --git a/lib/src/widgets/tooltip/tooltip.dart b/lib/src/widgets/tooltip/tooltip.dart new file mode 100644 index 00000000..bf1a2bf5 --- /dev/null +++ b/lib/src/widgets/tooltip/tooltip.dart @@ -0,0 +1,408 @@ +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/theme/colors.dart'; +import 'package:moon_design/src/theme/theme.dart'; +import 'package:moon_design/src/theme/typography/text_styles.dart'; +import 'package:moon_design/src/widgets/tooltip/obfuscate_tooltip_item.dart'; +import 'package:moon_design/src/widgets/tooltip/tooltip_content.dart'; +import 'package:moon_design/src/widgets/tooltip/tooltip_content_transition.dart'; +import 'package:moon_design/src/widgets/tooltip/tooltip_position_manager.dart'; + +enum MoonTooltipPosition { top, bottom, left, right, horizontal, vertical } + +class MoonTooltip extends StatefulWidget { + /// Sets a handler for listening to a `tap` event on the tooltip. + final void Function()? onTooltipTap; + + /// Controls the tooltip visibility. + final bool show; + + /// Whether the tooltip has an arrow (tail). + final bool hasArrow; + + /// Whether the tooltip should be dismissed whenever a user taps on it. For more control when to dismiss the tooltip + /// rely on the [show] property and [onTooltipTap] handler. + /// Defaults to [true]. + final bool hideOnTooltipTap; + + /// Sets the tooltip position relative to the target. + /// Defaults to [MoonTooltipPosition.top] + final MoonTooltipPosition tooltipPosition; + + /// 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 tooltip content. + final EdgeInsets? contentPadding; + + /// The width of the tooltip arrow (tail) at its base. + final double? arrowBaseWidth; + + /// The length of the tooltip arrow (tail). + final double? arrowLength; + + /// The offset of the tooltip arrow (tail) from the center of the tooltip. + final Offset? arrowOffset; + + /// The distance from the tip of the tooltip arrow (tail) to the target widget. + final double? arrowTipDistance; + + /// The width of the tooltip border. + final double borderWidth; + + /// The border radius value of the tooltip. + final double? borderRadius; + + /// The margin around tooltip. Used to prevent the tooltip from touching the edges of the viewport. + final double tooltipMargin; + + /// The color of the tooltip border. + final Color borderColor; + + /// The color of the tooltip background. + final Color? backgroundColor; + + /// List of tooltip shadows. + final List? tooltipShadows; + + /// Tooltip transition duration (fade in or out animation). + final Duration? transitionDuration; + + /// Tooltip transition curve (fade in or out animation). + final Curve? transitionCurve; + + /// `RouteObserver` used to listen for route changes that will hide the tooltip when the widget's route is not active. + final RouteObserver>? routeObserver; + + /// The widget that its placed inside the tooltip and functions as its content. + final Widget content; + + /// The [child] widget which the tooltip will target. + final Widget child; + + const MoonTooltip({ + super.key, + this.onTooltipTap, + required this.show, + this.hasArrow = true, + this.hideOnTooltipTap = true, + this.tooltipPosition = MoonTooltipPosition.top, + this.minWidth, + this.maxWidth, + this.minHeight, + this.maxHeight, + this.contentPadding, + this.arrowBaseWidth, + this.arrowLength, + this.arrowOffset, + this.arrowTipDistance, + this.borderRadius, + this.borderWidth = 0, + this.tooltipMargin = 8, + this.borderColor = Colors.transparent, + this.backgroundColor, + this.transitionDuration, + this.transitionCurve, + this.tooltipShadows, + this.routeObserver, + required this.content, + required this.child, + }); + + @override + MoonTooltipState createState() => MoonTooltipState(); +} + +class MoonTooltipState extends State with RouteAware { + // To avoid excessive rebuilds + final GlobalKey _positionManagerKey = GlobalKey(); + final LayerLink layerLink = LayerLink(); + final List _obfuscateItems = []; + + bool _displaying = false; + bool _routeIsShowing = true; + bool _isBeingObfuscated = false; + GlobalKey _transitionKey = GlobalKey(); + TooltipContentSize? _contentSize; + + late OverlayEntry _overlayEntry; + + bool get shouldShowTooltip => widget.show && !_isBeingObfuscated && _routeIsShowing; + + void addObfuscateItem(ObfuscateTooltipItemState item) { + _obfuscateItems.add(item); + WidgetsBinding.instance.addPostFrameCallback((_) { + doCheckForObfuscation(); + doShowOrHide(); + }); + } + + void removeObfuscatedItem(ObfuscateTooltipItemState item) { + _obfuscateItems.remove(item); + WidgetsBinding.instance.addPostFrameCallback((_) { + doCheckForObfuscation(); + doShowOrHide(); + }); + } + + void _showTooltip({bool buildHidding = false}) { + if (_displaying || !mounted) return; + + _overlayEntry = _buildOverlay(buildHidding: buildHidding); + Overlay.of(context).insert(_overlayEntry); + _displaying = true; + } + + void _removeTooltip() { + if (!_displaying) return; + + _overlayEntry.remove(); + _displaying = false; + } + + void doShowOrHide() { + final wasDisplaying = _displaying; + _removeTooltip(); + if (shouldShowTooltip) { + _showTooltip(); + } else if (wasDisplaying) { + _showTooltip(buildHidding: true); + } + } + + void doCheckForObfuscation() { + if (_contentSize == null) return; + + for (final obfuscateItem in _obfuscateItems) { + final d = obfuscateItem.getPositionAndSize()!; + final Rect obfuscateItemRect = d.globalPosition & d.size; + final Rect contentRect = _contentSize!.globalPosition & _contentSize!.size; + final bool overlaps = contentRect.overlaps(obfuscateItemRect); + + if (overlaps) { + _isBeingObfuscated = true; + // no need to keep searching + return; + } + } + + _isBeingObfuscated = false; + } + + Color _getTextColor({required Color backgroundColor}) { + final backgroundLuminance = backgroundColor.computeLuminance(); + if (backgroundLuminance > 0.5) { + return MoonColors.light.bulma; + } else { + return MoonColors.dark.bulma; + } + } + + @override + void didPush() { + _routeIsShowing = true; + // Route was pushed onto navigator and is now topmost route. + if (shouldShowTooltip) { + _removeTooltip(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _showTooltip(); + }); + } + } + + @override + void didPushNext() { + _routeIsShowing = false; + _removeTooltip(); + } + + @override + Future didPopNext() async { + _routeIsShowing = true; + + if (shouldShowTooltip) { + // Covering route was popped off the navigator. + _removeTooltip(); + await Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _showTooltip(); + }); + } + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (shouldShowTooltip) { + _showTooltip(); + } + widget.routeObserver?.subscribe(this, ModalRoute.of(context)! as PageRoute); + }); + } + + @override + void didUpdateWidget(MoonTooltip oldWidget) { + if (oldWidget.routeObserver != widget.routeObserver) { + oldWidget.routeObserver?.unsubscribe(this); + widget.routeObserver?.subscribe(this, ModalRoute.of(context)! as PageRoute); + } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (oldWidget.tooltipPosition != widget.tooltipPosition || (oldWidget.show != widget.show && widget.show)) { + _transitionKey = GlobalKey(); + } + if (!_routeIsShowing || _isBeingObfuscated) { + return; + } + doShowOrHide(); + }); + + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + _removeTooltip(); + widget.routeObserver?.unsubscribe(this); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: layerLink, + child: widget.child, + ); + } + + OverlayEntry _buildOverlay({bool buildHidding = false}) { + MoonTooltipPosition direction = widget.tooltipPosition; + + if (direction == MoonTooltipPosition.horizontal || direction == MoonTooltipPosition.vertical) { + // Compute real direction based on target position + final targetRenderBox = context.findRenderObject() as RenderBox?; + final overlayRenderBox = Overlay.of(context).context.findRenderObject() as RenderBox?; + + final targetGlobalCenter = + targetRenderBox?.localToGlobal(targetRenderBox.size.center(Offset.zero), ancestor: overlayRenderBox) ?? + Offset.zero; + + direction = (direction == MoonTooltipPosition.vertical) + ? (targetGlobalCenter.dy < overlayRenderBox!.size.center(Offset.zero).dy + ? MoonTooltipPosition.bottom + : MoonTooltipPosition.top) + : (targetGlobalCenter.dx < overlayRenderBox!.size.center(Offset.zero).dx + ? MoonTooltipPosition.right + : MoonTooltipPosition.left); + } + final double effectiveArrowBaseWidth = widget.arrowBaseWidth ?? context.moonTooltipTheme?.arrowBaseWidth ?? 16; + + final double effectiveArrowLength = + widget.hasArrow ? (widget.arrowLength ?? context.moonTooltipTheme?.arrowLength ?? 8) : 0; + + final double effectiveArrowTipDistance = widget.arrowTipDistance ?? context.moonTooltipTheme?.arrowTipDistance ?? 8; + + final Duration effectiveTransitionDuration = + widget.transitionDuration ?? context.moonTooltipTheme?.transitionDuration ?? const Duration(milliseconds: 150); + + final Curve effectiveTransitionCurve = + widget.transitionCurve ?? context.moonTooltipTheme?.transitionCurve ?? Curves.easeInOutCubic; + + final EdgeInsets effectiveContentPadding = + widget.contentPadding ?? context.moonTooltipTheme?.contentPadding ?? const EdgeInsets.all(12); + + final double effectiveBorderRadius = widget.borderRadius ?? context.moonTooltipTheme?.borderRadius.topLeft.x ?? 4; + + final Color effectiveBackgroundColor = + widget.backgroundColor ?? context.moonColors?.gohan ?? MoonColors.light.gohan; + + final Color effectiveTextColor = _getTextColor(backgroundColor: effectiveBackgroundColor); + + final TextStyle effectiveTextStyle = context.moonTooltipTheme?.textStyle.copyWith(color: effectiveTextColor) ?? + MoonTextStyles.text.text12.copyWith(color: effectiveTextColor); + + final List effectiveTooltipShadows = widget.tooltipShadows ?? + context.moonShadows?.sm ?? + const [ + BoxShadow( + color: Color(0x66000000), + blurRadius: 1, + ), + BoxShadow( + color: Color(0x28000000), + blurRadius: 6, + offset: Offset(0, 6), + ), + ]; + + return OverlayEntry( + builder: (overlayContext) { + return TooltipPositionManager( + key: _positionManagerKey, + context: context, + arrowLength: effectiveArrowLength, + arrowTipDistance: effectiveArrowTipDistance, + maxHeight: widget.maxHeight, + minHeight: widget.minHeight, + maxWidth: widget.maxWidth, + minWidth: widget.minWidth, + tooltipPosition: direction, + tooltipMargin: widget.tooltipMargin, + link: layerLink, + child: TooltipContentTransition( + key: _transitionKey, + hide: buildHidding, + tooltipPosition: direction, + duration: effectiveTransitionDuration, + curve: effectiveTransitionCurve, + onTransitionFinished: (status) { + if (status == AnimationStatus.dismissed) { + _removeTooltip(); + } + }, + child: TooltipContent( + tooltipPosition: direction, + borderRadius: effectiveBorderRadius, + arrowBaseWidth: effectiveArrowBaseWidth, + arrowLength: effectiveArrowLength, + arrowOffset: widget.arrowOffset, + arrowTipDistance: effectiveArrowTipDistance, + contentPadding: effectiveContentPadding, + borderWidth: widget.borderWidth, + borderColor: widget.borderColor, + backgroundColor: effectiveBackgroundColor, + shadows: effectiveTooltipShadows, + textStyle: effectiveTextStyle, + onTap: () { + if (widget.hideOnTooltipTap) { + _removeTooltip(); + _showTooltip(buildHidding: true); + } + + widget.onTooltipTap?.call(); + }, + onSizeChange: (contentSize) { + if (!mounted) return; + _contentSize = contentSize; + doCheckForObfuscation(); + doShowOrHide(); + }, + child: widget.content, + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/widgets/tooltip/tooltip_content.dart b/lib/src/widgets/tooltip/tooltip_content.dart new file mode 100644 index 00000000..86bd7ad3 --- /dev/null +++ b/lib/src/widgets/tooltip/tooltip_content.dart @@ -0,0 +1,356 @@ +import 'dart:math'; + +import 'package:figma_squircle/figma_squircle.dart'; +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/widgets/tooltip/tooltip.dart'; + +class TooltipContent extends StatefulWidget { + final GestureTapCallback? onTap; + final void Function(TooltipContentSize) onSizeChange; + final MoonTooltipPosition tooltipPosition; + final Offset? arrowOffset; + final double arrowBaseWidth; + final double arrowLength; + final double arrowTipDistance; + final double borderRadius; + final double borderWidth; + final Color backgroundColor; + final Color borderColor; + final EdgeInsets contentPadding; + final List shadows; + final TextStyle textStyle; + final Widget child; + + const TooltipContent({ + super.key, + this.onTap, + required this.onSizeChange, + required this.tooltipPosition, + this.arrowOffset, + required this.arrowBaseWidth, + required this.arrowLength, + required this.arrowTipDistance, + required this.borderRadius, + required this.borderWidth, + required this.backgroundColor, + required this.borderColor, + required this.contentPadding, + required this.shadows, + required this.textStyle, + required this.child, + }); + + @override + _TooltipContentState createState() => _TooltipContentState(); +} + +class _TooltipContentState extends State { + final GlobalKey _containerKey = GlobalKey(); + + TooltipContentSize? _lastSizeNotified; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final RenderBox? renderBox = _containerKey.currentContext!.findRenderObject() as RenderBox?; + + if (renderBox == null) return; + final position = renderBox.localToGlobal(Offset.zero); + final Size size = renderBox.size; + + if (_lastSizeNotified == null || + _lastSizeNotified!.size != size || + _lastSizeNotified!.globalPosition != position) { + final contentSize = TooltipContentSize( + size: size, + globalPosition: position, + context: context, + ); + + widget.onSizeChange(contentSize); + _lastSizeNotified = contentSize; + } + }); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: widget.onTap, + child: DefaultTextStyle( + style: widget.textStyle, + child: Container( + key: _containerKey, + padding: widget.contentPadding, + decoration: ShapeDecoration( + color: widget.backgroundColor, + shadows: widget.shadows, + shape: _TooltipContentShape( + tooltipPosition: widget.tooltipPosition, + arrowOffset: widget.arrowOffset, + arrowBaseWidth: widget.arrowBaseWidth, + arrowLength: widget.arrowLength, + arrowTipDistance: widget.arrowTipDistance, + borderColor: widget.borderColor, + borderRadius: widget.borderRadius, + borderWidth: widget.borderWidth, + ), + ), + child: widget.child, + ), + ), + ); + } +} + +class _TooltipContentShape extends ShapeBorder { + final MoonTooltipPosition tooltipPosition; + final Offset? arrowOffset; + final double arrowBaseWidth; + final double arrowLength; + final double arrowTipDistance; + final double borderRadius; + final double borderWidth; + final Color borderColor; + + const _TooltipContentShape({ + required this.tooltipPosition, + this.arrowOffset, + required this.arrowBaseWidth, + required this.arrowLength, + required this.arrowTipDistance, + required this.borderRadius, + required this.borderWidth, + required this.borderColor, + }); + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.zero; + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + return Path() + ..fillType = PathFillType.evenOdd + ..addPath(getOuterPath(rect), Offset.zero); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + late double topLeftRadius; + late double topRightRadius; + late double bottomLeftRadius; + late double bottomRightRadius; + + Path getLeftTopPath(Rect rect) { + return Path() + ..moveTo(rect.left, rect.bottom - bottomLeftRadius) + ..lineTo(rect.left, rect.top + topLeftRadius) + ..arcToPoint( + Offset(rect.left + topLeftRadius, rect.top), + radius: SmoothRadius(cornerRadius: topLeftRadius, cornerSmoothing: 1), + ) + ..lineTo(rect.right - topRightRadius, rect.top) + ..arcToPoint( + Offset(rect.right, rect.top + topRightRadius), + radius: SmoothRadius(cornerRadius: topRightRadius, cornerSmoothing: 1), + ); + } + + Path getBottomRightPath(Rect rect) { + return Path() + ..moveTo(rect.left + bottomLeftRadius, rect.bottom) + ..lineTo(rect.right - bottomRightRadius, rect.bottom) + ..arcToPoint( + Offset(rect.right, rect.bottom - bottomRightRadius), + radius: SmoothRadius(cornerRadius: bottomRightRadius, cornerSmoothing: 1), + clockwise: false, + ) + ..lineTo(rect.right, rect.top + topRightRadius) + ..arcToPoint( + Offset(rect.right - topRightRadius, rect.top), + radius: SmoothRadius(cornerRadius: topRightRadius, cornerSmoothing: 1), + clockwise: false, + ); + } + + topLeftRadius = borderRadius; + topRightRadius = borderRadius; + bottomLeftRadius = borderRadius; + bottomRightRadius = borderRadius; + + Offset arrowOffset = this.arrowOffset ?? rect.center; + + if (tooltipPosition == MoonTooltipPosition.right) { + arrowOffset = rect.centerLeft.translate(-arrowLength - arrowTipDistance, 0); + } else if (tooltipPosition == MoonTooltipPosition.left) { + arrowOffset = rect.centerRight.translate(arrowLength + arrowTipDistance, 0); + } + + switch (tooltipPosition) { + case MoonTooltipPosition.bottom: + return getBottomRightPath(rect) + ..lineTo( + min( + max(arrowOffset.dx + arrowBaseWidth / 2, rect.left + borderRadius + arrowBaseWidth), + rect.right - topRightRadius, + ), + rect.top, + ) + ..lineTo(arrowOffset.dx, rect.top - arrowLength) // up to arrow tip \ + ..lineTo( + max( + min(arrowOffset.dx - arrowBaseWidth / 2, rect.right - topLeftRadius - arrowBaseWidth), + rect.left + topLeftRadius, + ), + rect.top, + ) // down / + + ..lineTo(rect.left + topLeftRadius, rect.top) + ..arcToPoint( + Offset(rect.left, rect.top + topLeftRadius), + radius: SmoothRadius(cornerRadius: topLeftRadius, cornerSmoothing: 1), + clockwise: false, + ) + ..lineTo(rect.left, rect.bottom - bottomLeftRadius) + ..arcToPoint( + Offset(rect.left + bottomLeftRadius, rect.bottom), + radius: SmoothRadius(cornerRadius: bottomLeftRadius, cornerSmoothing: 1), + clockwise: false, + ); + + case MoonTooltipPosition.top: + return getLeftTopPath(rect) + ..lineTo(rect.right, rect.bottom - bottomRightRadius) + ..arcToPoint( + Offset(rect.right - bottomRightRadius, rect.bottom), + radius: SmoothRadius(cornerRadius: bottomRightRadius, cornerSmoothing: 1), + ) + ..lineTo( + min( + max(arrowOffset.dx + arrowBaseWidth / 2, rect.left + bottomLeftRadius + arrowBaseWidth), + rect.right - bottomRightRadius, + ), + rect.bottom, + ) + + // up to arrow tip \ + ..lineTo(arrowOffset.dx, rect.bottom + arrowLength) + + // down / + ..lineTo( + max( + min(arrowOffset.dx - arrowBaseWidth / 2, rect.right - bottomRightRadius - arrowBaseWidth), + rect.left + bottomLeftRadius, + ), + rect.bottom, + ) + ..lineTo(rect.left + bottomLeftRadius, rect.bottom) + ..arcToPoint( + Offset(rect.left, rect.bottom - bottomLeftRadius), + radius: SmoothRadius(cornerRadius: bottomLeftRadius, cornerSmoothing: 1), + ) + ..lineTo(rect.left, rect.top + topLeftRadius) + ..arcToPoint( + Offset(rect.left + topLeftRadius, rect.top), + radius: SmoothRadius(cornerRadius: topLeftRadius, cornerSmoothing: 1), + ); + + case MoonTooltipPosition.left: + return getLeftTopPath(rect) + ..lineTo( + rect.right, + max( + min(arrowOffset.dy - arrowBaseWidth / 2, rect.bottom - bottomRightRadius - arrowBaseWidth), + rect.top + topRightRadius, + ), + ) + ..lineTo(arrowOffset.dx - arrowTipDistance, arrowOffset.dy) // right to arrow tip \ + // left / + ..lineTo(rect.right, min(arrowOffset.dy + arrowBaseWidth / 2, rect.bottom - bottomRightRadius)) + ..lineTo(rect.right, rect.bottom - borderRadius) + ..arcToPoint( + Offset(rect.right - bottomRightRadius, rect.bottom), + radius: SmoothRadius(cornerRadius: bottomRightRadius, cornerSmoothing: 1), + ) + ..lineTo(rect.left + bottomLeftRadius, rect.bottom) + ..arcToPoint( + Offset(rect.left, rect.bottom - bottomLeftRadius), + radius: SmoothRadius(cornerRadius: bottomLeftRadius, cornerSmoothing: 1), + ); + + case MoonTooltipPosition.right: + return getBottomRightPath(rect) + ..lineTo(rect.left + topLeftRadius, rect.top) + ..arcToPoint( + Offset(rect.left, rect.top + topLeftRadius), + radius: SmoothRadius(cornerRadius: topLeftRadius, cornerSmoothing: 1), + clockwise: false, + ) + ..lineTo( + rect.left, + max( + min(arrowOffset.dy - arrowBaseWidth / 2, rect.bottom - bottomLeftRadius - arrowBaseWidth), + rect.top + topLeftRadius, + ), + ) + + //left to arrow tip / + ..lineTo(arrowOffset.dx + arrowTipDistance, arrowOffset.dy) + + // right \ + ..lineTo(rect.left, min(arrowOffset.dy + arrowBaseWidth / 2, rect.bottom - bottomLeftRadius)) + ..lineTo(rect.left, rect.bottom - bottomLeftRadius) + ..arcToPoint( + Offset(rect.left + bottomLeftRadius, rect.bottom), + radius: SmoothRadius(cornerRadius: bottomLeftRadius, cornerSmoothing: 1), + clockwise: false, + ); + + default: + throw AssertionError(tooltipPosition); + } + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + final Paint paint = Paint() + // if borderWidth is set to 0, set the color to be transparent to avoid border to be visible because strange behavior + ..color = borderWidth == 0 ? Colors.transparent : borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = borderWidth; + + canvas.drawPath(getOuterPath(rect), paint); + canvas.clipPath(getOuterPath(rect)); + } + + @override + ShapeBorder scale(double t) { + return _TooltipContentShape( + tooltipPosition: tooltipPosition, + arrowOffset: arrowOffset, + arrowBaseWidth: arrowBaseWidth, + arrowLength: arrowLength, + arrowTipDistance: arrowTipDistance, + borderRadius: borderRadius, + borderWidth: borderWidth, + borderColor: borderColor, + ); + } +} + +class TooltipContentSize { + final BuildContext context; + final Size size; + final Offset globalPosition; + + TooltipContentSize({ + required this.context, + required this.size, + required this.globalPosition, + }); +} diff --git a/lib/src/widgets/tooltip/tooltip_content_transition.dart b/lib/src/widgets/tooltip/tooltip_content_transition.dart new file mode 100644 index 00000000..3597ddbb --- /dev/null +++ b/lib/src/widgets/tooltip/tooltip_content_transition.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +import 'package:moon_design/src/widgets/tooltip/tooltip.dart'; + +class TooltipContentTransition extends StatefulWidget { + final void Function(AnimationStatus)? onTransitionFinished; + final MoonTooltipPosition tooltipPosition; + final bool hide; + final Duration duration; + final Curve curve; + final Widget child; + + const TooltipContentTransition({ + super.key, + this.onTransitionFinished, + required this.tooltipPosition, + this.hide = false, + required this.duration, + required this.curve, + required this.child, + }); + + @override + TooltipContentTransitionState createState() => TooltipContentTransitionState(); +} + +class TooltipContentTransitionState extends State with SingleTickerProviderStateMixin { + late AnimationController animationController = AnimationController( + vsync: this, + duration: widget.duration, + ); + + late CurvedAnimation curvedAnimation = CurvedAnimation( + curve: Curves.easeInOutCubic, + parent: animationController, + ); + + @override + void initState() { + super.initState(); + + if (!widget.hide) { + animationController.forward(); + } else { + animationController.reverse(); + } + + animationController.addStatusListener((status) { + if ((status == AnimationStatus.completed || status == AnimationStatus.dismissed) && + widget.onTransitionFinished != null) { + widget.onTransitionFinished!(status); + } + }); + } + + @override + void didUpdateWidget(TooltipContentTransition oldWidget) { + if (widget.hide) { + animationController.reverse(); + } + + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: FadeTransition(opacity: curvedAnimation, child: widget.child), + ); + } +} diff --git a/lib/src/widgets/tooltip/tooltip_position_manager.dart b/lib/src/widgets/tooltip/tooltip_position_manager.dart new file mode 100644 index 00000000..978bcace --- /dev/null +++ b/lib/src/widgets/tooltip/tooltip_position_manager.dart @@ -0,0 +1,303 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:moon_design/src/widgets/tooltip/tooltip.dart'; + +class TooltipPositionManager extends StatefulWidget { + final BuildContext context; + final MoonTooltipPosition tooltipPosition; + final double arrowTipDistance; + final double arrowLength; + final double? maxWidth; + final double? maxHeight; + final double? minWidth; + final double? minHeight; + final double tooltipMargin; + final LayerLink link; + final Widget child; + + const TooltipPositionManager({ + super.key, + required this.context, + required this.tooltipPosition, + required this.arrowTipDistance, + required this.arrowLength, + required this.maxWidth, + required this.maxHeight, + required this.minWidth, + required this.minHeight, + required this.tooltipMargin, + required this.link, + required this.child, + }); + + @override + _TooltipPositionManagerState createState() => _TooltipPositionManagerState(); +} + +class _TooltipPositionManagerState extends State { + final GlobalKey _contentKey = GlobalKey(); + + Size? _contentSize; + + @override + void initState() { + super.initState(); + } + + @override + void didUpdateWidget(TooltipPositionManager oldWidget) { + if (widget.tooltipPosition != oldWidget.tooltipPosition) { + // invalidate content size to perform recalculation + _contentSize = null; + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext _) { + final RenderBox? renderBox = widget.context.findRenderObject() as RenderBox?; + if (renderBox?.attached == false) return Container(); + + final cOverlay = Overlay.of(widget.context); + + if (!cOverlay.mounted) return Container(); + + final RenderBox? overlay = cOverlay.context.findRenderObject() as RenderBox?; + + if (overlay == null || renderBox?.hasSize == false) return Container(); + + late Offset tipTarget; + + const Offset zeroOffset = Offset.zero; + + try { + if (widget.tooltipPosition == MoonTooltipPosition.top) { + tipTarget = renderBox!.size.topCenter(zeroOffset); + } else if (widget.tooltipPosition == MoonTooltipPosition.bottom) { + tipTarget = renderBox!.size.bottomCenter(zeroOffset); + } else if (widget.tooltipPosition == MoonTooltipPosition.right) { + tipTarget = renderBox!.size.centerRight(zeroOffset); + } else if (widget.tooltipPosition == MoonTooltipPosition.left) { + tipTarget = renderBox!.size.centerLeft(zeroOffset); + } + } catch (e) { + return Container(); + } + + final globalTipTarget = renderBox?.localToGlobal( + tipTarget, + ancestor: overlay, + ); + + final content = CustomSingleChildLayout( + delegate: _PopupContentLayoutDelegate( + arrowLength: widget.arrowLength, + arrowTipDistance: widget.arrowTipDistance, + maxHeight: widget.maxHeight, + maxWidth: widget.maxWidth, + minHeight: widget.minHeight, + minWidth: widget.minWidth, + tooltipPosition: widget.tooltipPosition, + tipTarget: globalTipTarget!, + tooltipMargin: widget.tooltipMargin, + ), + child: Stack( + clipBehavior: Clip.none, + fit: StackFit.passthrough, + children: [ + Positioned( + child: Container( + key: _contentKey, + child: widget.child, + ), + ), + ], + ), + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final contentContext = _contentKey.currentContext; + if (contentContext != null) { + final contentSize = contentContext.size!; + + final wasNull = _contentSize == null; + _contentSize = contentSize; + + if (wasNull) { + setState(() {}); + } + } + }); + + final offset = getPositionForChild(_contentSize, overlay, globalTipTarget); + + return Stack( + children: [ + CompositedTransformFollower( + link: widget.link, + showWhenUnlinked: false, + offset: tipTarget.translate(offset.dx, offset.dy), // + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + child: Transform.translate( + offset: Offset.zero, + child: Container( + alignment: AlignmentDirectional.bottomStart, + child: content, + ), + ), + ), + ], + ), + ), + ], + ); + } + + Offset getPositionForChild( + Size? childSize, + RenderBox overlay, + Offset globalTipTarget, + ) { + if (childSize == null) { + return Offset.zero; + } + Offset contentOffset; + final double halfH = childSize.height / 2; + final double halfW = childSize.width / 2; + final Offset centerPosition = Offset(-halfW, -halfH); + if (widget.tooltipPosition == MoonTooltipPosition.top) { + final double yOffset = -halfH - widget.arrowLength - widget.arrowTipDistance; + contentOffset = centerPosition.translate(0, yOffset); + final maxXOffset = overlay.size.width; + final globalcontentRightBoundingOffset = globalTipTarget.dx + contentOffset.dx + childSize.width; + if (globalcontentRightBoundingOffset > maxXOffset) { + contentOffset = contentOffset.translate( + maxXOffset - globalcontentRightBoundingOffset - widget.tooltipMargin, + 0, + ); + } + const minXOffset = 0; + final globalcontentLeftBoundingOffset = globalTipTarget.dx + contentOffset.dx; + if (globalcontentLeftBoundingOffset < minXOffset) { + contentOffset = contentOffset.translate( + minXOffset - globalcontentLeftBoundingOffset + widget.tooltipMargin, + 0, + ); + } + } else if (widget.tooltipPosition == MoonTooltipPosition.bottom) { + final double yOffset = halfH + widget.arrowLength + widget.arrowTipDistance; + contentOffset = centerPosition.translate(0, yOffset); + } else if (widget.tooltipPosition == MoonTooltipPosition.right) { + final double xOffset = halfW + widget.arrowLength + widget.arrowTipDistance; + contentOffset = centerPosition.translate(xOffset, 0); + final maxXOffset = overlay.size.width; + final globalcontentRightBoundingOffset = globalTipTarget.dx + contentOffset.dx + childSize.width; + if (globalcontentRightBoundingOffset > maxXOffset) { + contentOffset = contentOffset.translate( + maxXOffset - globalcontentRightBoundingOffset - widget.tooltipMargin, + 0, + ); + } + } else if (widget.tooltipPosition == MoonTooltipPosition.left) { + final double xOffset = -halfW - widget.arrowLength - widget.arrowTipDistance; + contentOffset = centerPosition.translate(xOffset, 0); + const minXOffset = 0; + final globalcontentLeftBoundingOffset = globalTipTarget.dx + contentOffset.dx; + if (globalcontentLeftBoundingOffset < minXOffset) { + contentOffset = contentOffset.translate( + minXOffset - globalcontentLeftBoundingOffset + widget.tooltipMargin, + 0, + ); + } + } else { + contentOffset = centerPosition; + } + return contentOffset; + } +} + +class _PopupContentLayoutDelegate extends SingleChildLayoutDelegate { + final double? maxWidth; + final double? maxHeight; + final double? minWidth; + final double? minHeight; + final MoonTooltipPosition tooltipPosition; + final double arrowTipDistance; + final double arrowLength; + final Offset tipTarget; + final double tooltipMargin; + + _PopupContentLayoutDelegate({ + required this.maxWidth, + required this.maxHeight, + required this.minWidth, + required this.minHeight, + required this.tooltipPosition, + required this.arrowLength, + required this.arrowTipDistance, + required this.tipTarget, + required this.tooltipMargin, + }); + + @override + bool shouldRelayout(_PopupContentLayoutDelegate oldDelegate) { + return oldDelegate.tipTarget.dx != tipTarget.dx || oldDelegate.tipTarget.dy != tipTarget.dy; + } + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + final double minWidth = this.minWidth ?? constraints.minWidth; + final double minHeight = this.minHeight ?? constraints.minHeight; + + double maxWidth = this.maxWidth ?? constraints.maxWidth; + double maxHeight = this.maxHeight ?? constraints.maxHeight; + + if (tooltipPosition == MoonTooltipPosition.top || tooltipPosition == MoonTooltipPosition.bottom) { + maxWidth = max( + min( + (constraints.maxWidth - tipTarget.dx).abs() * 2 - tooltipMargin, + (tipTarget.dx).abs() * 2 - tooltipMargin, + ), + 0, + ); + maxHeight = max( + constraints.maxHeight - tooltipMargin, + 0, + ); + } else if (tooltipPosition == MoonTooltipPosition.right) { + maxWidth = max( + (constraints.maxWidth - tipTarget.dx).abs() - tooltipMargin - arrowLength - arrowTipDistance, + 0, + ); + maxHeight = max( + constraints.maxHeight - tooltipMargin, + 0, + ); + } else if (tooltipPosition == MoonTooltipPosition.left) { + maxWidth = max( + min( + tipTarget.dx >= constraints.maxWidth + ? (constraints.maxWidth - tipTarget.dx).abs() - tooltipMargin - arrowLength - arrowTipDistance + : maxWidth, + tipTarget.dx - (tooltipMargin * 2) - arrowTipDistance, + ), + 0, + ); + maxHeight = max( + constraints.maxHeight - tooltipMargin, + 0, + ); + } + + return BoxConstraints( + maxHeight: this.maxHeight ?? max(maxHeight, minHeight), + maxWidth: this.maxWidth ?? max(maxWidth, minWidth), + minHeight: this.minHeight ?? minHeight, + minWidth: this.minWidth ?? minWidth, + ); + } +}