diff --git a/CHANGELOG.md b/CHANGELOG.md index 43da4b3c..65e64bc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## [4.0.0] + +- Feature ✨: Added Action widget for tooltip + ## [3.0.1] - Feature [#475](https://github.com/SimformSolutionsPvtLtd/flutter_showcaseview/issues/475) - Add diff --git a/README.md b/README.md index 425b8665..effd4256 100644 --- a/README.md +++ b/README.md @@ -131,55 +131,96 @@ WidgetsBinding.instance.addPostFrameCallback((_) => | onFinish | VoidCallback? | | Triggered when all the showcases are completed | | enableShowcase | bool | true | Enable or disable showcase globally. | | toolTipMargin | double | 14 | For tooltip margin | +| globalTooltipActionConfig | TooltipActionConfig? | | Global tooltip actionbar config | +| globalTooltipActions | List? | | Global list of tooltip actions | ## Properties of `Showcase` and `Showcase.withWidget`: -| Name | Type | Default Behaviour | Description | `Showcase` | `ShowCaseWidget` | -|------------------------------|------------------|--------------------------------------------------|----------------------------------------------------------------------------------------------------|------------|------------------| -| key | GlobalKey | | Unique Global key for each showcase. | ✅ | ✅ | -| child | Widget | | The Target widget that you want to be showcased | ✅ | ✅ | -| title | String? | | Title of default tooltip | ✅ | | -| description | String? | | Description of default tooltip | ✅ | | -| container | Widget? | | Allows to create custom tooltip widget. | | ✅ | -| height | double? | | Height of custom tooltip widget | | ✅ | -| width | double? | | Width of custom tooltip widget | | ✅ | -| titleTextStyle | TextStyle? | | Text Style of title | ✅ | | -| descTextStyle | TextStyle? | | Text Style of description | ✅ | | -| titleAlignment | TextAlign | TextAlign.start | Alignment of title | ✅ | | -| descriptionAlignment | TextAlign | TextAlign.start | Alignment of description | ✅ | | -| targetShapeBorder | ShapeBorder | | If `targetBorderRadius` param is not provided then it applies shape border to target widget | ✅ | ✅ | -| targetBorderRadius | BorderRadius? | | Border radius of target widget | ✅ | ✅ | -| tooltipBorderRadius | BorderRadius? | BorderRadius.circular(8.0) | Border radius of tooltip | ✅ | | -| blurValue | double? | `ShowCaseWidget.blurValue` | Gaussian blur effect on overlay | ✅ | ✅ | -| tooltipPadding | EdgeInsets | EdgeInsets.symmetric(vertical: 8, horizontal: 8) | Padding to tooltip content | ✅ | | -| targetPadding | EdgeInsets | EdgeInsets.zero | Padding to target widget | ✅ | ✅ | -| overlayOpacity | double | 0.75 | Opacity of overlay layer | ✅ | ✅ | -| overlayColor | Color | Colors.black45 | Color of overlay layer | ✅ | ✅ | -| tooltipBackgroundColor | Color | Colors.white | Background Color of default tooltip | ✅ | | -| textColor | Color | Colors.black | Color of tooltip text | ✅ | | -| scrollLoadingWidget | Widget | | Loading widget on overlay until active showcase is visible to viewport when `autoScroll` is enable | ✅ | ✅ | -| movingAnimationDuration | Duration | Duration(milliseconds: 2000) | Duration of time this moving animation should last. | ✅ | ✅ | -| showArrow | bool | true | Shows tooltip with arrow | ✅ | | -| disableDefaultTargetGestures | bool | false | disable default gestures of target widget | ✅ | ✅ | -| disposeOnTap | bool? | false | Dismiss all showcases on target/tooltip tap | ✅ | ✅ | -| disableMovingAnimation | bool? | `ShowCaseWidget.disableMovingAnimation` | Disable bouncing/moving transition | ✅ | ✅ | -| disableScaleAnimation | bool? | `ShowCaseWidget.disableScaleAnimation` | Disable initial scale transition when showcase is being started and completed | ✅ | | -| scaleAnimationDuration | Duration | Duration(milliseconds: 300) | Duration of time scale animation should last. | ✅ | | -| scaleAnimationCurve | Curve | Curves.easeIn | Curve to use in scale animation. | ✅ | | -| scaleAnimationAlignment | Alignment? | | Origin of the coordinate in which the scale takes place, relative to the size of the box. | ✅ | | -| onToolTipClick | VoidCallback? | | Triggers when tooltip is being clicked. | ✅ | | -| onTargetClick | VoidCallback? | | Triggers when target widget is being clicked | ✅ | ✅ | -| onTargetDoubleTap | VoidCallback? | | Triggers when target widget is being double clicked | ✅ | ✅ | -| onTargetLongPress | VoidCallback? | | Triggers when target widget is being long pressed | ✅ | ✅ | -| onBarrierClick | VoidCallback? | | Triggers when barrier is clicked | ✅ | ✅ | -| tooltipPosition | TooltipPosition? | | Defines vertical position of tooltip respective to Target widget | ✅ | ✅ | -| titlePadding | EdgeInsets? | EdgeInsets.zero | Padding to title | ✅ | | -| descriptionPadding | EdgeInsets? | EdgeInsets.zero | Padding to description | ✅ | | -| titleTextDirection | TextDirection? | | Give textDirection to title | ✅ | | -| descriptionTextDirection | TextDirection? | | Give textDirection to description | ✅ | | -| descriptionTextDirection | TextDirection? | | Give textDirection to description | ✅ | | -| disableBarrierInteraction | bool | false | Disables barrier interaction for a particular showCase | ✅ | ✅ | -| toolTipSlideEndDistance | double | 7 | Defines motion range for tooltip slide animation | ✅ | ✅ | +| Name | Type | Default Behaviour | Description | `Showcase` | `ShowCaseWidget` | +|------------------------------|----------------------------|--------------------------------------------------|----------------------------------------------------------------------------------------------------|------------|------------------| +| key | GlobalKey | | Unique Global key for each showcase. | ✅ | ✅ | +| child | Widget | | The Target widget that you want to be showcased | ✅ | ✅ | +| title | String? | | Title of default tooltip | ✅ | | +| description | String? | | Description of default tooltip | ✅ | | +| container | Widget? | | Allows to create custom tooltip widget. | | ✅ | +| height | double? | | Height of custom tooltip widget | | ✅ | +| width | double? | | Width of custom tooltip widget | | ✅ | +| titleTextStyle | TextStyle? | | Text Style of title | ✅ | | +| descTextStyle | TextStyle? | | Text Style of description | ✅ | | +| titleAlignment | TextAlign | TextAlign.start | Alignment of title | ✅ | | +| descriptionAlignment | TextAlign | TextAlign.start | Alignment of description | ✅ | | +| targetShapeBorder | ShapeBorder | | If `targetBorderRadius` param is not provided then it applies shape border to target widget | ✅ | ✅ | +| targetBorderRadius | BorderRadius? | | Border radius of target widget | ✅ | ✅ | +| tooltipBorderRadius | BorderRadius? | BorderRadius.circular(8.0) | Border radius of tooltip | ✅ | | +| blurValue | double? | `ShowCaseWidget.blurValue` | Gaussian blur effect on overlay | ✅ | ✅ | +| tooltipPadding | EdgeInsets | EdgeInsets.symmetric(vertical: 8, horizontal: 8) | Padding to tooltip content | ✅ | | +| targetPadding | EdgeInsets | EdgeInsets.zero | Padding to target widget | ✅ | ✅ | +| overlayOpacity | double | 0.75 | Opacity of overlay layer | ✅ | ✅ | +| overlayColor | Color | Colors.black45 | Color of overlay layer | ✅ | ✅ | +| tooltipBackgroundColor | Color | Colors.white | Background Color of default tooltip | ✅ | | +| textColor | Color | Colors.black | Color of tooltip text | ✅ | | +| scrollLoadingWidget | Widget | | Loading widget on overlay until active showcase is visible to viewport when `autoScroll` is enable | ✅ | ✅ | +| movingAnimationDuration | Duration | Duration(milliseconds: 2000) | Duration of time this moving animation should last. | ✅ | ✅ | +| showArrow | bool | true | Shows tooltip with arrow | ✅ | | +| disableDefaultTargetGestures | bool | false | disable default gestures of target widget | ✅ | ✅ | +| disposeOnTap | bool? | false | Dismiss all showcases on target/tooltip tap | ✅ | ✅ | +| disableMovingAnimation | bool? | `ShowCaseWidget.disableMovingAnimation` | Disable bouncing/moving transition | ✅ | ✅ | +| disableScaleAnimation | bool? | `ShowCaseWidget.disableScaleAnimation` | Disable initial scale transition when showcase is being started and completed | ✅ | | +| scaleAnimationDuration | Duration | Duration(milliseconds: 300) | Duration of time scale animation should last. | ✅ | | +| scaleAnimationCurve | Curve | Curves.easeIn | Curve to use in scale animation. | ✅ | | +| scaleAnimationAlignment | Alignment? | | Origin of the coordinate in which the scale takes place, relative to the size of the box. | ✅ | | +| onToolTipClick | VoidCallback? | | Triggers when tooltip is being clicked. | ✅ | | +| onTargetClick | VoidCallback? | | Triggers when target widget is being clicked | ✅ | ✅ | +| onTargetDoubleTap | VoidCallback? | | Triggers when target widget is being double clicked | ✅ | ✅ | +| onTargetLongPress | VoidCallback? | | Triggers when target widget is being long pressed | ✅ | ✅ | +| onBarrierClick | VoidCallback? | | Triggers when barrier is clicked | ✅ | ✅ | +| tooltipPosition | TooltipPosition? | | Defines vertical position of tooltip respective to Target widget | ✅ | ✅ | +| titlePadding | EdgeInsets? | EdgeInsets.zero | Padding to title | ✅ | | +| descriptionPadding | EdgeInsets? | EdgeInsets.zero | Padding to description | ✅ | | +| titleTextDirection | TextDirection? | | Give textDirection to title | ✅ | | +| descriptionTextDirection | TextDirection? | | Give textDirection to description | ✅ | | +| descriptionTextDirection | TextDirection? | | Give textDirection to description | ✅ | | +| disableBarrierInteraction | bool | false | Disables barrier interaction for a particular showCase | ✅ | ✅ | +| toolTipSlideEndDistance | double | 7 | Defines motion range for tooltip slide animation | ✅ | ✅ | +| tooltipActions | List? | [] | Provide a list of tooltip actions | ✅ | ✅ | +| tooltipActionConfig | TooltipActionConfig? | | Give configurations (alignment, position, etc...) to the tooltip actionbar | ✅ | ✅ | + +## Properties of `TooltipActionButton.withDefault` and `TooltipActionButton.custom`: + +| Name | Type | Default Behaviour | Description | `TooltipActionButton.withDefault` | `TooltipActionButton.custom` | +|---------------------------|---------------------|--------------------------------------------------|------------------------------------------------|-----------------------------------|------------------------------| +| button | Widget | | Provide custom tooltip action button widget | | ✅ | +| type | TooltipActionButton | | Type of action button (next, skip, previous) | ✅ | | +| backgroundColor | Color? | | Give background color to action button | ✅ | | +| borderRadius | BorderRadius? | BorderRadius.all(Radius.circular(50)) | Give border radius to action button | ✅ | | +| textStyle | TextStyle? | | Give text styles to the name of button | ✅ | | +| padding | EdgeInsets? | EdgeInsets.symmetric(horizontal: 15,vertical: 4) | Give padding to button content | ✅ | | +| leadIcon | ActionButtonIcon? | | Add icon at first before name in action button | ✅ | | +| tailIcon | ActionButtonIcon? | | Add icon at last after name in action button | ✅ | | +| name | String? | | Action button name | ✅ | | +| onTap | VoidCallback? | | Triggers when action button is tapped | ✅ | | +| borderWidth | double? | 0 | Give border width to action button | ✅ | | +| borderColor | Color? | Colors.white | Give color to the border of action button | ✅ | | +| shouldShowForFirstTooltip | bool? | true | Should show button on first tooltip | ✅ | | +| shouldShowForLastTooltip | bool? | true | Should show button on last tooltip | ✅ | | + +## Properties of `TooltipActionConfig`: + +| Name | Type | Default Behaviour | Description | +|----------------------------|-------------------------|:-----------------------------|-------------------------------------------------------------------| +| alignment | TooltipActionAlignment? | TooltipActionAlignment.left | Alignment of tooltip action buttons (left, right, center, spread) | +| actionGap | double? | 5 | Horizontal gap between the tooltip action buttons | +| padding | EdgeInsets? | EdgeInsets.zero | Padding to the tooltip actionbar | +| position | TooltipActionPosition? | TooltipActionPosition.inside | Position of tooltip actionbar (inside, outside) | +| gapBetweenContentAndAction | double? | 10 | Gap between tooltip content and actionbar | + +## Properties of `ActionButtonIcon.withIcon` and `ActionButtonIcon.withImageIcon`: + +| Name | Type | Default Behaviour | Description | `ActionButtonIcon.withIcon` | `ActionButtonIcon.withImageIcon` | +|---------|-------------|-------------------|------------------------------------|-----------------------------|----------------------------------| +| icon | Icon | | Provide a icon to the button | ✅ | | +| icon | ImageIcon | | Provide a image icon to the button | | ✅ | +| padding | EdgeInsets? | | Give padding to the icon | ✅ | ✅ | ## How to use diff --git a/example/lib/main.dart b/example/lib/main.dart index 950c78c6..317af350 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -37,6 +37,10 @@ class MyApp extends StatelessWidget { blurValue: 1, autoPlayDelay: const Duration(seconds: 3), builder: (context) => const MailPage(), + globalTooltipActionConfig: const TooltipActionConfig( + position: TooltipActionPosition.inside, + alignment: MainAxisAlignment.spaceBetween, + ), ), ), ); @@ -180,6 +184,17 @@ class _MailPageState extends State { description: 'Tap to see menu options', onBarrierClick: () => debugPrint('Barrier clicked'), + tooltipActionConfig: + const TooltipActionConfig( + alignment: MainAxisAlignment.end, + position: TooltipActionPosition.outside, + gapBetweenContentAndAction: 10, + ), + tooltipActions: [ + TooltipActionButton.withDefault( + type: TooltipDefaultActionType.next, + ), + ], child: GestureDetector( onTap: () => debugPrint('menu button clicked'), @@ -221,7 +236,34 @@ class _MailPageState extends State { "Tap to see profile which contains user's name, profile picture, mobile number and country", tooltipBackgroundColor: Theme.of(context).primaryColor, textColor: Colors.white, + onTargetClick: () { + print('target cliecked'); + }, + disposeOnTap: false, + onToolTipClick: () { + print('clicked tool tip'); + }, + disableDefaultTargetGestures: true, targetShapeBorder: const CircleBorder(), + tooltipActionConfig: const TooltipActionConfig( + alignment: MainAxisAlignment.spaceBetween, + gapBetweenContentAndAction: 10, + position: TooltipActionPosition.outside, + ), + tooltipActions: [ + TooltipActionButton.withDefault( + backgroundColor: Colors.transparent, + type: TooltipDefaultActionType.previous, + padding: EdgeInsets.zero, + ), + TooltipActionButton.withDefault( + type: TooltipDefaultActionType.next, + backgroundColor: Colors.white, + textStyle: const TextStyle( + color: Colors.pinkAccent, + ), + ), + ], child: Container( padding: const EdgeInsets.all(5), width: 45, @@ -277,6 +319,35 @@ class _MailPageState extends State { title: 'Compose Mail', description: 'Click here to compose mail', targetShapeBorder: const CircleBorder(), + showArrow: false, + tooltipActionConfig: const TooltipActionConfig( + alignment: MainAxisAlignment.spaceBetween, + actionGap: 12, + ), + tooltipActions: [ + TooltipActionButton.withDefault( + type: TooltipDefaultActionType.previous, + name: 'Back', + onTap: () { + // Write your code on button tap + ShowCaseWidget.of(context).previous(); + }, + backgroundColor: Colors.pink.shade50, + textStyle: const TextStyle( + color: Colors.pink, + )), + TooltipActionButton.withDefault( + type: TooltipDefaultActionType.skip, + name: 'Close', + tailIcon: const ActionButtonIcon.withIcon( + icon: Icon( + Icons.close, + color: Colors.white, + size: 15, + ), + ), + ), + ], child: FloatingActionButton( backgroundColor: Theme.of(context).primaryColor, onPressed: () { @@ -311,27 +382,57 @@ class _MailPageState extends State { child: Container( padding: const EdgeInsets.symmetric(vertical: 8), child: Showcase( - key: key, - description: 'Tap to check mail', - tooltipPosition: TooltipPosition.top, - disposeOnTap: true, - onTargetClick: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const Detail(), + key: key, + description: 'Tap to check mail', + disposeOnTap: true, + tooltipActionConfig: const TooltipActionConfig( + alignment: MainAxisAlignment.spaceBetween, + actionGap: 15, + position: TooltipActionPosition.outside, + gapBetweenContentAndAction: 16, + ), + tooltipActions: [ + TooltipActionButton.withDefault( + type: TooltipDefaultActionType.previous, + name: 'Back', + onTap: () { + // Write your code on button tap + ShowCaseWidget.of(context).previous(); + }, + backgroundColor: Colors.pink.shade50, + textStyle: const TextStyle( + color: Colors.pink, + )), + TooltipActionButton.withDefault( + type: TooltipDefaultActionType.skip, + name: 'Close', + tailIcon: const ActionButtonIcon.withIcon( + icon: Icon( + Icons.close, + color: Colors.white, + size: 15, ), - ).then((_) { - setState(() { - ShowCaseWidget.of(context).startShowCase([_four, _five]); - }); + ), + ), + ], + onTargetClick: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const Detail(), + ), + ).then((_) { + setState(() { + ShowCaseWidget.of(context).startShowCase([_four, _five]); }); - }, - child: MailTile( - mail: mail, - showCaseKey: _four, - showCaseDetail: showCaseDetail, - )), + }); + }, + child: MailTile( + mail: mail, + showCaseKey: _four, + showCaseDetail: showCaseDetail, + ), + ), ), ); } @@ -412,39 +513,66 @@ class MailTile extends StatelessWidget { key: showCaseKey!, height: 50, width: 140, + tooltipActionConfig: const TooltipActionConfig( + alignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + ), + tooltipActions: [ + TooltipActionButton.withDefault( + backgroundColor: Colors.transparent, + type: TooltipDefaultActionType.previous, + padding: EdgeInsets.zero, + ), + TooltipActionButton.withDefault( + type: TooltipDefaultActionType.next, + backgroundColor: Colors.white, + textStyle: const TextStyle( + color: Colors.pinkAccent, + ), + ), + ], targetShapeBorder: const CircleBorder(), targetBorderRadius: const BorderRadius.all( Radius.circular(150), ), - container: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 45, - height: 45, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xffFCD8DC), - ), - child: Center( - child: Text( - 'S', - style: TextStyle( - color: Theme.of(context).primaryColor, - fontWeight: FontWeight.bold, - fontSize: 16, + container: Container( + padding: const EdgeInsets.all(10), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.all( + Radius.circular(15), + ), + ), + width: 140, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 45, + height: 45, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xffFCD8DC), + ), + child: Center( + child: Text( + 'S', + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), ), ), - ), - const SizedBox( - height: 10, - ), - const Text( - "Your sender's profile ", - style: TextStyle(color: Colors.white), - ) - ], + const SizedBox( + height: 10, + ), + const Text( + "Your sender's profile", + ) + ], + ), ), child: const SAvatarExampleChild(), ) diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index ae8ff59d..ec9aa6bb 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ this == inside; + + bool get isOutside => this == outside; +} + +enum TooltipActionAlignment { + left(MainAxisAlignment.start), + right(MainAxisAlignment.end), + spread(MainAxisAlignment.spaceBetween), + center(MainAxisAlignment.center); + + const TooltipActionAlignment(this.alignment); + + final MainAxisAlignment alignment; +} + +enum TooltipDefaultActionType { + next(actionName: 'Next'), + skip(actionName: 'Skip'), + previous(actionName: 'Previous'); + + const TooltipDefaultActionType({ + required this.actionName, + }); + + final String actionName; + + void onTap(ShowCaseWidgetState showCaseState) { + switch (this) { + case TooltipDefaultActionType.next: + showCaseState.next(); + break; + case TooltipDefaultActionType.previous: + showCaseState.previous(); + break; + case TooltipDefaultActionType.skip: + showCaseState.dismiss(); + break; + default: + throw ArgumentError('Invalid tooltip default action type'); + } + } +} diff --git a/lib/src/layout_overlays.dart b/lib/src/layout_overlays.dart index d8516b70..6c14f6fd 100644 --- a/lib/src/layout_overlays.dart +++ b/lib/src/layout_overlays.dart @@ -65,7 +65,9 @@ class AnchoredOverlay extends StatelessWidget { overlayBuilder: (overlayContext) { // To calculate the "anchor" point we grab the render box of // our parent Container and then we find the center of that box. - final box = context.findRenderObject() as RenderBox; + final box = context.findRenderObject() as RenderBox?; + + if (box == null) return const SizedBox.shrink(); final topLeft = box.size.topLeft( box.localToGlobal( diff --git a/lib/src/models/action_button_icon.dart b/lib/src/models/action_button_icon.dart new file mode 100644 index 00000000..da067045 --- /dev/null +++ b/lib/src/models/action_button_icon.dart @@ -0,0 +1,17 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class ActionButtonIcon { + const ActionButtonIcon.withIcon({ + required this.icon, + this.padding, + }) : assert(icon is Icon, 'Icon must be of type Icon'); + + const ActionButtonIcon.withImageIcon({ + required this.icon, + this.padding, + }) : assert(icon is ImageIcon, 'Icon must be of type ImageIcon'); + + final Widget icon; + final EdgeInsets? padding; +} diff --git a/lib/src/models/tooltip_action_button.dart b/lib/src/models/tooltip_action_button.dart new file mode 100644 index 00000000..b806c474 --- /dev/null +++ b/lib/src/models/tooltip_action_button.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; + +import '../../showcaseview.dart'; + +class TooltipActionButton { + /// To Provide Background color to the action in [TooltipActionButton.withDefault] + final Color? backgroundColor; + + /// To Provide borderRadius to the action in [TooltipActionButton.withDefault] + final BorderRadius? borderRadius; + + /// To Provide textStyle to the action text in [TooltipActionButton.withDefault] + final TextStyle? textStyle; + + /// To Provide padding to the action widget + final EdgeInsets? padding; + + /// To Provide a custom widget for the action in [TooltipActionButton.custom] + final Widget? button; + + /// To Provide a leading icon for the action in [TooltipActionButton.withDefault] + final ActionButtonIcon? leadIcon; + + /// To Provide a tail icon for the action in [TooltipActionButton.withDefault] + final ActionButtonIcon? tailIcon; + + /// To Provide a action type in [TooltipActionButton.withDefault] + final TooltipDefaultActionType? type; + + /// To Provide a text for action in [TooltipActionButton.withDefault] + final String? name; + + /// To Provide a onTap for action in [TooltipActionButton.withDefault] + final VoidCallback? onTap; + + /// To Provide a border for action in [TooltipActionButton.withDefault] + final double? borderWidth; + + /// To Provide a borderColor for action in [TooltipActionButton.withDefault] + final Color? borderColor; + + /// To show or hide action for the first tooltip defaults to [true] + final bool shouldShowForFirstTooltip; + + /// To show or hide action for the hide tooltip defaults to [true] + final bool shouldShowForLastTooltip; + + TooltipActionButton.withDefault({ + required this.type, + this.backgroundColor, + this.textStyle = const TextStyle( + color: Colors.white, + ), + this.borderRadius = const BorderRadius.all( + Radius.circular(50), + ), + this.padding = const EdgeInsets.symmetric( + horizontal: 15, + vertical: 4, + ), + this.leadIcon, + this.tailIcon, + this.name, + this.onTap, + this.borderColor, + this.borderWidth, + this.shouldShowForFirstTooltip = true, + this.shouldShowForLastTooltip = true, + }) : button = null; + + TooltipActionButton.custom({ + required this.button, + this.shouldShowForFirstTooltip = true, + this.shouldShowForLastTooltip = true, + }) : backgroundColor = null, + borderRadius = null, + textStyle = null, + padding = null, + leadIcon = null, + tailIcon = null, + type = null, + name = null, + onTap = null, + borderColor = null, + borderWidth = null; +} diff --git a/lib/src/models/tooltip_action_config.dart b/lib/src/models/tooltip_action_config.dart new file mode 100644 index 00000000..074b4253 --- /dev/null +++ b/lib/src/models/tooltip_action_config.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../../showcaseview.dart'; + +class TooltipActionConfig { + const TooltipActionConfig({ + this.alignment = MainAxisAlignment.spaceBetween, + this.actionGap = 5, + this.padding = EdgeInsets.zero, + this.position = TooltipActionPosition.inside, + this.gapBetweenContentAndAction = 10, + this.crossAxisAlignment = CrossAxisAlignment.start, + }); + + /// Defines tooltip action widget position. + /// It can be inside the tooltip widget or outside. + /// + /// Default to [TooltipActionPosition.inside] + final TooltipActionPosition position; + + /// Defines the alignment of actions buttons of tooltip action widget + /// + /// Default to [TooltipActionAlignment.left] + final MainAxisAlignment alignment; + + /// Defines the gap between the actions buttons of tooltip action widget + /// + /// Default to 5.0 + final double actionGap; + + /// Defines the padding in the tooltip action widget + /// + /// Default to [EdgeInsets.zero] + final EdgeInsets padding; + + /// Defines vertically gap between tooltip content and actions. + /// + /// Default to 10.0 + final double gapBetweenContentAndAction; + + /// Defines running direction alignment for the Action widgets. + /// + /// Default to [crossAxisAlignment.start] + final CrossAxisAlignment crossAxisAlignment; +} diff --git a/lib/src/showcase.dart b/lib/src/showcase.dart index a4d98644..4c3ce8e9 100644 --- a/lib/src/showcase.dart +++ b/lib/src/showcase.dart @@ -26,11 +26,10 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'enum.dart'; +import '../showcaseview.dart'; import 'get_position.dart'; import 'layout_overlays.dart'; import 'shape_clipper.dart'; -import 'showcase_widget.dart'; import 'tooltip_widget.dart'; class Showcase extends StatefulWidget { @@ -258,6 +257,19 @@ class Showcase extends StatefulWidget { /// Defaults to 14. final double toolTipMargin; + /// Defines tooltip action widget position. + /// It can be inside the tooltip widget or outside. + /// Provides tooTip action widgets at bottom in tooltip. + /// + /// one can use [TooltipActionButton] class to use default action + final List? tooltipActions; + + /// Provide a configuration for tooltip action widget like alignment, + /// position, gap, etc... + /// + /// Default to [const TooltipActionConfig()] + final TooltipActionConfig? tooltipActionConfig; + const Showcase({ required this.key, required this.description, @@ -299,12 +311,14 @@ class Showcase extends StatefulWidget { this.tooltipPosition, this.titlePadding, this.descriptionPadding, + this.tooltipActions, this.titleTextDirection, this.descriptionTextDirection, this.onBarrierClick, this.disableBarrierInteraction = false, this.toolTipSlideEndDistance = 7, this.toolTipMargin = 14, + this.tooltipActionConfig, }) : height = null, width = null, container = null, @@ -346,6 +360,8 @@ class Showcase extends StatefulWidget { this.onBarrierClick, this.disableBarrierInteraction = false, this.toolTipSlideEndDistance = 7, + this.tooltipActions, + this.tooltipActionConfig, }) : showArrow = false, onToolTipClick = null, scaleAnimationDuration = const Duration(milliseconds: 300), @@ -641,11 +657,60 @@ class _ShowcaseState extends State { descriptionTextDirection: widget.descriptionTextDirection, toolTipSlideEndDistance: widget.toolTipSlideEndDistance, toolTipMargin: widget.toolTipMargin, + tooltipActionConfig: _getTooltipActionConfig(), + tooltipActions: _getTooltipActions(), ), ], ], ); } + + List _getTooltipActions() { + final showCaseState = ShowCaseWidget.of(context); + final actionData = (widget.tooltipActions?.isEmpty ?? true) + ? showCaseState.globalTooltipActions ?? [] + : widget.tooltipActions ?? []; + + final actionWidgets = []; + for (var action = 0; action < actionData.length; action++) { + /// This checks that if it is first or last tooltip and + /// [shouldShowForLastTooltip] or [shouldShowForFirstTooltip] is true + /// then we will ignore that action + if (((showCaseState.activeWidgetId == 0 && + actionData[action].shouldShowForFirstTooltip) || + (showCaseState.activeWidgetId == + (showCaseState.ids?.length ?? 0) - 1 && + !actionData[action].shouldShowForLastTooltip)) && + (widget.tooltipActions?.isEmpty ?? true)) { + continue; + } + actionWidgets.add( + Padding( + padding: EdgeInsetsDirectional.only( + end: action < actionData.length - 1 + ? _getTooltipActionConfig().actionGap + : 0, + ), + child: TooltipActionButtonWidget( + config: actionData[action], + showCaseState: ShowCaseWidget.of(context), + ), + ), + ); + } + return actionWidgets; + } + + TooltipActionConfig _getTooltipActionConfig() { + final showCaseState = ShowCaseWidget.of(context); + if (widget.tooltipActionConfig != null) { + return widget.tooltipActionConfig!; + } else if (showCaseState.globalTooltipActionConfig != null) { + return showCaseState.globalTooltipActionConfig!; + } else { + return const TooltipActionConfig(); + } + } } class _TargetWidget extends StatelessWidget { diff --git a/lib/src/showcase_widget.dart b/lib/src/showcase_widget.dart index 16168ba6..8676cc85 100644 --- a/lib/src/showcase_widget.dart +++ b/lib/src/showcase_widget.dart @@ -83,6 +83,12 @@ class ShowCaseWidget extends StatefulWidget { /// Enable/disable showcase globally. Enabled by default. final bool enableShowcase; + /// Global action to apply on every tooltip widget + final List? globalTooltipActions; + + /// Global Config for tooltip action to auto apply for all the toolTip + final TooltipActionConfig? globalTooltipActionConfig; + const ShowCaseWidget({ required this.builder, this.onFinish, @@ -98,6 +104,8 @@ class ShowCaseWidget extends StatefulWidget { this.enableAutoScroll = false, this.disableBarrierInteraction = false, this.enableShowcase = true, + this.globalTooltipActionConfig, + this.globalTooltipActions, }); static GlobalKey? activeTargetWidget(BuildContext context) { @@ -126,6 +134,10 @@ class ShowCaseWidgetState extends State { Size? rootWidgetSize; Key? anchoredOverlayKey; + late final TooltipActionConfig? globalTooltipActionConfig; + + late final List? globalTooltipActions; + /// These properties are only here so that it can be accessed by /// [Showcase] bool get autoPlay => widget.autoPlay; @@ -152,6 +164,8 @@ class ShowCaseWidgetState extends State { @override void initState() { super.initState(); + globalTooltipActions = widget.globalTooltipActions; + globalTooltipActionConfig = widget.globalTooltipActionConfig; initRootWidget(); } diff --git a/lib/src/tooltip_action_button_widget.dart b/lib/src/tooltip_action_button_widget.dart new file mode 100644 index 00000000..64d70f85 --- /dev/null +++ b/lib/src/tooltip_action_button_widget.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +import '../showcaseview.dart'; + +class TooltipActionButtonWidget extends StatelessWidget { + const TooltipActionButtonWidget({ + super.key, + required this.config, + required this.showCaseState, + }); + + final TooltipActionButton config; + final ShowCaseWidgetState showCaseState; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return config.button ?? + GestureDetector( + onTap: handleOnTap, + child: Container( + padding: config.padding, + decoration: BoxDecoration( + color: config.backgroundColor ?? theme.primaryColor, + borderRadius: config.borderRadius, + border: Border.all( + color: config.borderColor ?? + config.backgroundColor ?? + theme.primaryColor, + width: config.borderWidth ?? 0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (config.leadIcon != null) + Padding( + padding: config.leadIcon?.padding ?? + const EdgeInsets.only(right: 5), + child: config.leadIcon?.icon, + ), + Text( + config.name ?? config.type?.actionName ?? '', + style: config.textStyle, + ), + if (config.tailIcon != null) + Padding( + padding: config.tailIcon?.padding ?? + const EdgeInsets.only(left: 5), + child: config.tailIcon?.icon, + ), + ], + ), + ), + ); + } + + void handleOnTap() { + if (config.onTap != null) { + config.onTap?.call(); + } else { + config.type?.onTap(showCaseState); + } + } +} diff --git a/lib/src/tooltip_widget.dart b/lib/src/tooltip_widget.dart index d4b67034..016133c3 100644 --- a/lib/src/tooltip_widget.dart +++ b/lib/src/tooltip_widget.dart @@ -27,6 +27,8 @@ import 'package:flutter/material.dart'; import 'enum.dart'; import 'get_position.dart'; import 'measure_size.dart'; +import 'models/tooltip_action_config.dart'; +import 'widget/action_widget.dart'; import 'widget/tooltip_slide_transition.dart'; class ToolTipWidget extends StatefulWidget { @@ -62,6 +64,8 @@ class ToolTipWidget extends StatefulWidget { final TextDirection? descriptionTextDirection; final double toolTipSlideEndDistance; final double toolTipMargin; + final TooltipActionConfig tooltipActionConfig; + final List tooltipActions; const ToolTipWidget({ super.key, @@ -97,6 +101,8 @@ class ToolTipWidget extends StatefulWidget { this.titleTextDirection, this.descriptionTextDirection, this.toolTipSlideEndDistance = 7, + required this.tooltipActionConfig, + required this.tooltipActions, }); @override @@ -115,12 +121,22 @@ class _ToolTipWidgetState extends State late final Animation _scaleAnimation; double tooltipWidth = 0; - double tooltipScreenEdgePadding = 20; - double tooltipTextPadding = 15; + + // This is Default height considered at the start of this package + double tooltipHeight = 120; + + final _withArrowToolTipPadding = 22.0; + final _withOutArrowToolTipPadding = 10.0; + + // To store Tooltip action size + Size? _tooltipActionSize; + + // This is used when [_tooltipActionSize] is already calculated and + // on change of something we are recalculating the size of the widget + bool isSizeRecalculating = false; TooltipPosition findPositionForContent(Offset position) { - var height = 120.0; - height = widget.contentHeight ?? height; + var height = tooltipHeight; final bottomPosition = position.dy + ((widget.position?.getHeight() ?? 0) / 2); final topPosition = position.dy - ((widget.position?.getHeight() ?? 0) / 2); @@ -143,7 +159,37 @@ class _ToolTipWidgetState extends State : TooltipPosition.bottom); } - void _getTooltipWidth() { + /// This will calculate the width and height of the tooltip + void _getTooltipSize() { + Size? toolTipActionSize; + // if tooltip action is there this will calculate the height of that + if (widget.tooltipActions.isNotEmpty) { + final renderBox = + _actionWidgetKey.currentContext?.findRenderObject() as RenderBox?; + + // if first frame is drawn then only we will be able to calculate the + // size of the action widget + if (renderBox != null) { + toolTipActionSize = _tooltipActionSize = renderBox.size; + isSizeRecalculating = false; + } else if (_tooltipActionSize == null || renderBox == null) { + // If first frame is not drawn then we will schedule the rebuild after + // the first frame is drawn + isSizeRecalculating = true; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (mounted) { + _getTooltipSize(); + setState(() {}); + } + }); + // If size is calculated once then we will wait for first frame + // to draw before calculating anything as that may cause a flicker + // in the tooltip + if (isSizeRecalculating && _tooltipActionSize != null) { + return; + } + } + } final titleStyle = widget.titleTextStyle ?? Theme.of(context) .textTheme @@ -154,26 +200,75 @@ class _ToolTipWidgetState extends State .textTheme .titleSmall! .merge(TextStyle(color: widget.textColor)); - final titleLength = widget.title == null - ? 0 - : _textSize(widget.title!, titleStyle).width + - widget.tooltipPadding!.right + - widget.tooltipPadding!.left + - (widget.titlePadding?.right ?? 0) + - (widget.titlePadding?.left ?? 0); - final descriptionLength = widget.description == null - ? 0 - : (_textSize(widget.description!, descriptionStyle).width + - widget.tooltipPadding!.right + - widget.tooltipPadding!.left + - (widget.descriptionPadding?.right ?? 0) + - (widget.descriptionPadding?.left ?? 0)); - var maxTextWidth = max(titleLength, descriptionLength); - if (maxTextWidth > widget.screenSize.width - tooltipScreenEdgePadding) { - tooltipWidth = widget.screenSize.width - tooltipScreenEdgePadding; + + // This is to calculate the size of the title text + // We have passed padding so we get the perfect width of the Title + final titleSize = _textSize( + widget.title, + titleStyle, + widget.titlePadding, + ); + + // This is to calculate the size of the description text + // We have passed padding so we get the perfect width of the Title + final descriptionSize = _textSize( + widget.description, + descriptionStyle, + widget.descriptionPadding, + ); + final titleLength = widget.title == null ? 0 : titleSize!.width; + final descriptionLength = + widget.description == null ? 0 : descriptionSize!.width; + var maxTextWidth = + max(toolTipActionSize?.width ?? 0, max(titleLength, descriptionLength)); + // if Width is greater than available size which won't happen we will + // adjust it to stay in available size + if (maxTextWidth > widget.screenSize.width - (2 * widget.toolTipMargin)) { + tooltipWidth = widget.screenSize.width - (2 * widget.toolTipMargin); } else { - tooltipWidth = maxTextWidth + tooltipTextPadding; + // This is padding we will have around the tooltip text + final textPadding = + (widget.tooltipPadding ?? EdgeInsets.zero).horizontal + + max((widget.titlePadding ?? EdgeInsets.zero).horizontal, + (widget.descriptionPadding ?? EdgeInsets.zero).horizontal); + + // Final tooltip width will be text width + padding around the tool tip + // Here we have not considered the margin around the tooltip as that + // doesn't count in width of the tooltip + if ((toolTipActionSize?.width ?? 0) >= + (max(titleLength, descriptionLength) + textPadding)) { + tooltipWidth = toolTipActionSize?.width ?? 0; + } else { + tooltipWidth = maxTextWidth + textPadding; + } } + + // If user has provided the width then we will use the maximum of action + // width and user provided width + if (widget.contentWidth != null) { + tooltipWidth = max(toolTipActionSize?.width ?? 0, widget.contentWidth!); + } + + final arrowHeight = widget.showArrow + ? _withArrowToolTipPadding + : _withOutArrowToolTipPadding; + // Login to calculate the tooltip height + // Text height + padding above and below of text + arrow height + extra + // space provided between target widget and tooltip widget + + // tooltip slide end distance + toolTip action Size + + // 20 for the extra space so it won't stick to any side + + tooltipHeight = (widget.tooltipPadding ?? EdgeInsets.zero).vertical + + (titleSize?.height ?? 0) + + (descriptionSize?.height ?? 0) + + arrowHeight - + 1 + + 3 + + widget.toolTipSlideEndDistance + + (toolTipActionSize?.height ?? + widget.tooltipActionConfig.gapBetweenContentAndAction) + + (widget.contentHeight ?? 0) + + 20; } double? _getLeft() { @@ -250,6 +345,7 @@ class _ToolTipWidgetState extends State } final GlobalKey _customContainerKey = GlobalKey(); + final GlobalKey _actionWidgetKey = GlobalKey(); final ValueNotifier _customContainerWidth = ValueNotifier(1); @override @@ -316,20 +412,26 @@ class _ToolTipWidgetState extends State @override void didChangeDependencies() { super.didChangeDependencies(); - _getTooltipWidth(); + // If tooltip is dismissing then no need to recalculate the size and widgets + if (!widget.isTooltipDismissed) { + _getTooltipSize(); + } } @override void didUpdateWidget(covariant ToolTipWidget oldWidget) { super.didUpdateWidget(oldWidget); - _getTooltipWidth(); + // If tooltip is dismissing then no need to recalculate the size and widgets + // If widget is same as before then also no need to calculate + if (!widget.isTooltipDismissed && oldWidget.hashCode != hashCode) { + _getTooltipSize(); + } } @override void dispose() { _movingAnimationController.dispose(); _scaleAnimationController.dispose(); - super.dispose(); } @@ -342,19 +444,27 @@ class _ToolTipWidgetState extends State contentOrientation == TooltipPosition.bottom ? 1.0 : -1.0; isArrowUp = contentOffsetMultiplier == 1.0; - final contentY = isArrowUp + var contentY = isArrowUp ? widget.position!.getBottom() + (contentOffsetMultiplier * 3) : widget.position!.getTop() + (contentOffsetMultiplier * 3); + // if tooltip is going out of screen in bottom this will ensure it is + // visible above the widget + // Here 20 is added to have some space at the bottom of the tooltip + if (contentY + tooltipHeight >= MediaQuery.of(context).size.height && + isArrowUp) { + contentY = MediaQuery.of(context).size.height - tooltipHeight - 20; + } + final num contentFractionalOffset = contentOffsetMultiplier.clamp(-1.0, 0.0); - var paddingTop = isArrowUp ? 22.0 : 0.0; - var paddingBottom = isArrowUp ? 0.0 : 22.0; + var paddingTop = isArrowUp ? _withArrowToolTipPadding : 0.0; + var paddingBottom = isArrowUp ? 0.0 : _withArrowToolTipPadding; if (!widget.showArrow) { - paddingTop = 10; - paddingBottom = 10; + paddingTop = _withOutArrowToolTipPadding; + paddingBottom = _withOutArrowToolTipPadding; } const arrowWidth = 18.0; @@ -388,103 +498,217 @@ class _ToolTipWidgetState extends State ).animate(_movingAnimation), child: Material( type: MaterialType.transparency, - child: Container( - padding: widget.showArrow - ? EdgeInsets.only( - top: paddingTop - (isArrowUp ? arrowHeight : 0), - bottom: paddingBottom - (isArrowUp ? 0 : arrowHeight), - ) - : null, - child: Stack( - alignment: isArrowUp - ? Alignment.topLeft - : _getLeft() == null - ? Alignment.bottomRight - : Alignment.bottomLeft, - children: [ - if (widget.showArrow) - Positioned( - left: _getArrowLeft(arrowWidth), - right: _getArrowRight(arrowWidth), - child: CustomPaint( - painter: _Arrow( - strokeColor: widget.tooltipBackgroundColor!, - strokeWidth: 10, - paintingStyle: PaintingStyle.fill, - isUpArrow: isArrowUp, + child: Column( + children: [ + if (widget.tooltipActions.isNotEmpty && + widget.tooltipActionConfig.position.isOutside && + !isArrowUp) + ActionWidget( + alignment: widget.tooltipActionConfig.alignment, + tooltipActionConfig: widget.tooltipActionConfig, + width: _tooltipActionSize == null ? null : tooltipWidth, + crossAxisAlignment: + widget.tooltipActionConfig.crossAxisAlignment, + outSidePadding: _tooltipActionSize == null + ? EdgeInsets.only( + left: widget.tooltipPadding?.left ?? 0, + right: widget.tooltipPadding?.right ?? 0, + ) + : EdgeInsets.zero, + isArrowUp: isArrowUp, + children: widget.tooltipActions, + ), + Container( + padding: widget.showArrow + ? EdgeInsets.only( + top: paddingTop - (isArrowUp ? arrowHeight : 0), + bottom: + paddingBottom - (isArrowUp ? 0 : arrowHeight), + ) + : null, + child: Stack( + alignment: isArrowUp + ? Alignment.topLeft + : _getLeft() == null + ? Alignment.bottomRight + : Alignment.bottomLeft, + children: [ + // This widget is used for calculation of the action + // widget size and it will be removed once the size + // is calculated + if (isSizeRecalculating) + Offstage( + child: ActionWidget( + key: _actionWidgetKey, + outSidePadding: widget + .tooltipActionConfig.position.isInside + ? EdgeInsets.only( + left: widget.tooltipPadding?.left ?? 0, + right: + widget.tooltipPadding?.right ?? 0, + ) + : EdgeInsets.zero, + tooltipActionConfig: widget.tooltipActionConfig, + alignment: widget.tooltipActionConfig.alignment, + width: null, + crossAxisAlignment: widget + .tooltipActionConfig.crossAxisAlignment, + isArrowUp: true, + children: widget.tooltipActions, + ), ), - child: const SizedBox( - height: arrowHeight, - width: arrowWidth, + if (widget.showArrow) + Positioned( + left: _getArrowLeft(arrowWidth), + right: _getArrowRight(arrowWidth), + child: CustomPaint( + painter: _Arrow( + strokeColor: widget.tooltipBackgroundColor!, + strokeWidth: 10, + paintingStyle: PaintingStyle.fill, + isUpArrow: isArrowUp, + ), + child: const SizedBox( + height: arrowHeight, + width: arrowWidth, + ), + ), ), - ), - ), - Padding( - padding: EdgeInsets.only( - top: isArrowUp ? arrowHeight - 1 : 0, - bottom: isArrowUp ? 0 : arrowHeight - 1, - ), - child: ClipRRect( - borderRadius: widget.tooltipBorderRadius ?? - BorderRadius.circular(8.0), - child: GestureDetector( - onTap: widget.onTooltipTap, - child: Container( - width: tooltipWidth, - padding: widget.tooltipPadding, - color: widget.tooltipBackgroundColor, - child: Column( - crossAxisAlignment: widget.title != null - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - children: [ - if (widget.title != null) - Padding( - padding: widget.titlePadding ?? - EdgeInsets.zero, - child: Text( - widget.title!, - textAlign: widget.titleAlignment, - textDirection: - widget.titleTextDirection, - style: widget.titleTextStyle ?? - Theme.of(context) - .textTheme - .titleLarge! - .merge( - TextStyle( - color: widget.textColor, + Padding( + padding: EdgeInsets.only( + top: isArrowUp ? arrowHeight - 1 : 0, + bottom: isArrowUp ? 0 : arrowHeight - 1, + ), + child: ClipRRect( + borderRadius: widget.tooltipBorderRadius ?? + BorderRadius.circular(8.0), + child: GestureDetector( + onTap: widget.onTooltipTap, + child: Container( + width: tooltipWidth, + padding: widget.tooltipPadding?.copyWith( + left: 0, + right: 0, + ), + color: widget.tooltipBackgroundColor, + child: Column( + crossAxisAlignment: widget.title != null + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (widget.title != null) + Padding( + padding: (widget.titlePadding ?? + EdgeInsets.zero) + .add( + EdgeInsets.only( + left: + widget.tooltipPadding?.left ?? + 0, + right: widget + .tooltipPadding?.right ?? + 0, + ), + ), + child: Text( + widget.title!, + textAlign: widget.titleAlignment, + textDirection: + widget.titleTextDirection, + style: widget.titleTextStyle ?? + Theme.of(context) + .textTheme + .titleLarge! + .merge( + TextStyle( + color: widget.textColor, + ), + ), + ), + ), + Padding( + padding: (widget.descriptionPadding ?? + EdgeInsets.zero) + .add( + EdgeInsets.only( + left: widget.tooltipPadding?.left ?? + 0, + right: + widget.tooltipPadding?.right ?? + 0, + ), + ), + child: Text( + widget.description!, + textAlign: + widget.descriptionAlignment, + textDirection: + widget.descriptionTextDirection, + style: widget.descTextStyle ?? + Theme.of(context) + .textTheme + .titleSmall! + .merge( + TextStyle( + color: widget.textColor, + ), ), - ), + ), ), - ), - Padding( - padding: widget.descriptionPadding ?? - EdgeInsets.zero, - child: Text( - widget.description!, - textAlign: widget.descriptionAlignment, - textDirection: - widget.descriptionTextDirection, - style: widget.descTextStyle ?? - Theme.of(context) - .textTheme - .titleSmall! - .merge( - TextStyle( - color: widget.textColor, - ), - ), - ), + if (widget.tooltipActions.isNotEmpty && + widget.tooltipActionConfig.position + .isInside && + _tooltipActionSize != null) + ActionWidget( + outSidePadding: EdgeInsets.only( + left: widget.tooltipPadding?.left ?? + 0, + right: + widget.tooltipPadding?.right ?? + 0, + ), + tooltipActionConfig: + widget.tooltipActionConfig, + alignment: widget + .tooltipActionConfig.alignment, + width: _tooltipActionSize == null + ? null + : tooltipWidth, + crossAxisAlignment: widget + .tooltipActionConfig + .crossAxisAlignment, + isArrowUp: true, + children: widget.tooltipActions, + ), + ], ), - ], + ), ), ), ), - ), + ], ), - ], - ), + ), + if (widget.tooltipActions.isNotEmpty && + widget.tooltipActionConfig.position.isOutside && + isArrowUp || + (_tooltipActionSize == null && isArrowUp)) + ActionWidget( + alignment: widget.tooltipActionConfig.alignment, + tooltipActionConfig: widget.tooltipActionConfig, + isArrowUp: isArrowUp, + width: _tooltipActionSize == null ? null : tooltipWidth, + crossAxisAlignment: + widget.tooltipActionConfig.crossAxisAlignment, + outSidePadding: _tooltipActionSize == null + ? EdgeInsets.only( + left: widget.tooltipPadding?.left ?? 0, + right: widget.tooltipPadding?.right ?? 0, + ) + : EdgeInsets.zero, + children: widget.tooltipActions, + ), + ], ), ), ), @@ -518,9 +742,80 @@ class _ToolTipWidgetState extends State ), color: Colors.transparent, child: Center( - child: MeasureSize( - onSizeChange: onSizeChange, - child: widget.container, + child: Column( + children: [ + Stack( + children: [ + // This widget is used for calculation of the action + // widget size and it will be removed once the size + // is calculated + // We have kept it in colum because if we put is + // outside in the stack then it will take whole + // screen size and width calculation will fail + if (isSizeRecalculating) + Offstage( + child: ActionWidget( + key: _actionWidgetKey, + tooltipActionConfig: + widget.tooltipActionConfig, + alignment: + widget.tooltipActionConfig.alignment, + crossAxisAlignment: widget + .tooltipActionConfig.crossAxisAlignment, + width: null, + isArrowUp: isArrowUp, + children: widget.tooltipActions, + ), + ), + // This offset is used to make animation smoother + // when there is big action widget which make + // the tool tip to change it's position + Offstage( + offstage: _tooltipActionSize == null, + child: Column( + children: [ + if (widget.tooltipActions.isNotEmpty && + !isArrowUp) + ActionWidget( + tooltipActionConfig: + widget.tooltipActionConfig, + alignment: widget + .tooltipActionConfig.alignment, + crossAxisAlignment: widget + .tooltipActionConfig + .crossAxisAlignment, + width: _tooltipActionSize == null + ? null + : tooltipWidth, + isArrowUp: isArrowUp, + children: widget.tooltipActions, + ), + MeasureSize( + onSizeChange: onSizeChange, + child: widget.container, + ), + if (widget.tooltipActions.isNotEmpty && + isArrowUp) + ActionWidget( + alignment: widget + .tooltipActionConfig.alignment, + tooltipActionConfig: + widget.tooltipActionConfig, + width: _tooltipActionSize == null + ? null + : tooltipWidth, + crossAxisAlignment: widget + .tooltipActionConfig + .crossAxisAlignment, + isArrowUp: isArrowUp, + children: widget.tooltipActions, + ), + ], + ), + ), + ], + ) + ], ), ), ), @@ -539,15 +834,30 @@ class _ToolTipWidgetState extends State setState(() => position = tempPos); } - Size _textSize(String text, TextStyle style) { + Size? _textSize(String? text, TextStyle style, EdgeInsets? padding) { + if (text == null) { + return null; + } + + final availableSpaceForText = + (widget.position?.screenWidth ?? MediaQuery.of(context).size.width) - + (padding ?? EdgeInsets.zero).horizontal - + (widget.tooltipPadding ?? EdgeInsets.zero).horizontal - + (2 * widget.toolTipMargin); + final textPainter = TextPainter( text: TextSpan(text: text, style: style), - maxLines: 1, + // TODO: replace this once we support sdk v3.12. // ignore: deprecated_member_use textScaleFactor: MediaQuery.of(context).textScaleFactor, textDirection: TextDirection.ltr, - )..layout(); + textWidthBasis: TextWidthBasis.longestLine, + )..layout( + // This is used to make maintain the text in available space so height + // and width calculation will be accurate + maxWidth: availableSpaceForText, + ); return textPainter.size; } diff --git a/lib/src/widget/action_widget.dart b/lib/src/widget/action_widget.dart new file mode 100644 index 00000000..7a4b8e71 --- /dev/null +++ b/lib/src/widget/action_widget.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import '../../showcaseview.dart'; + +class ActionWidget extends StatelessWidget { + const ActionWidget({ + super.key, + required this.children, + required this.tooltipActionConfig, + required this.alignment, + required this.crossAxisAlignment, + required this.isArrowUp, + this.outSidePadding = EdgeInsets.zero, + this.width, + }); + + final TooltipActionConfig tooltipActionConfig; + final List children; + final double? width; + final MainAxisAlignment alignment; + final CrossAxisAlignment crossAxisAlignment; + final EdgeInsets outSidePadding; + final bool isArrowUp; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: Padding( + padding: EdgeInsets.only( + top: isArrowUp ? tooltipActionConfig.gapBetweenContentAndAction : 0.0, + bottom: + !isArrowUp ? tooltipActionConfig.gapBetweenContentAndAction : 0.0, + ).add(outSidePadding), + child: Row( + mainAxisSize: width == null ? MainAxisSize.min : MainAxisSize.max, + mainAxisAlignment: + width == null ? MainAxisAlignment.start : alignment, + crossAxisAlignment: crossAxisAlignment, + children: children, + ), + ), + ); + } +}