diff --git a/example/lib/main.dart b/example/lib/main.dart index 9829dfc3d..7cdee8137 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -308,19 +308,27 @@ class _MyHomePageState extends State with WindowListener { ), ), PaneItem( - icon: const Icon(FluentIcons.hint_text), - title: const Text('Tooltip'), + icon: const Icon(FluentIcons.pop_expand), + title: const Text('Flyout'), body: DeferredWidget( surfaces.loadLibrary, - () => popups.TooltipPage(), + () => popups.Flyout2Screen(), ), ), PaneItem( - icon: const Icon(FluentIcons.pop_expand), - title: const Text('Flyout'), + icon: const Icon(FluentIcons.field_filled), + title: const Text('Teaching Tip'), body: DeferredWidget( surfaces.loadLibrary, - () => popups.Flyout2Screen(), + () => surfaces.TeachingTipPage(), + ), + ), + PaneItem( + icon: const Icon(FluentIcons.hint_text), + title: const Text('Tooltip'), + body: DeferredWidget( + surfaces.loadLibrary, + () => popups.TooltipPage(), ), ), PaneItemHeader(header: const Text('Theming')), diff --git a/example/lib/routes/surfaces.dart b/example/lib/routes/surfaces.dart index 543c5a970..caad0a2b8 100644 --- a/example/lib/routes/surfaces.dart +++ b/example/lib/routes/surfaces.dart @@ -3,4 +3,5 @@ export '../screens/surface/commandbars.dart'; export '../screens/surface/expander.dart'; export '../screens/surface/info_bars.dart'; export '../screens/surface/progress_indicators.dart'; +export '../screens/popups/teaching_tip.dart'; export '../screens/surface/tiles.dart'; diff --git a/example/lib/screens/popups/teaching_tip.dart b/example/lib/screens/popups/teaching_tip.dart new file mode 100644 index 000000000..a4da9f160 --- /dev/null +++ b/example/lib/screens/popups/teaching_tip.dart @@ -0,0 +1,211 @@ +import 'package:example/theme.dart'; +import 'package:example/widgets/card_highlight.dart'; +import 'package:example/widgets/page.dart'; +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:provider/provider.dart'; + +class TeachingTipPage extends StatefulWidget { + const TeachingTipPage({Key? key}) : super(key: key); + + @override + State createState() => _TeachingTipPageState(); +} + +class _TeachingTipPageState extends State with PageMixin { + final nonTargetedController = FlyoutController(); + + static const alignments = { + 'Bottom left': Alignment.bottomLeft, + 'Bottom center': Alignment.bottomCenter, + 'Bottom right': Alignment.bottomRight, + 'Center': Alignment.center, + 'Top left': Alignment.topLeft, + 'Top center': Alignment.topCenter, + 'Top right': Alignment.topRight, + }; + static const placements = { + 'Bottom left': FlyoutPlacementMode.bottomLeft, + 'Bottom center': FlyoutPlacementMode.bottomCenter, + 'Bottom right': FlyoutPlacementMode.bottomRight, + 'Center': FlyoutPlacementMode.left, + 'Top left': FlyoutPlacementMode.topLeft, + 'Top center': FlyoutPlacementMode.topCenter, + 'Top right': FlyoutPlacementMode.topRight, + }; + late String alignment = 'Bottom center'; + final targetedController = FlyoutController(); + + @override + void dispose() { + nonTargetedController.dispose(); + targetedController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = FluentTheme.of(context); + final appTheme = context.watch(); + + return ScaffoldPage.scrollable( + header: const PageHeader(title: Text('Teaching Tip')), + children: [ + description( + content: const Text( + 'A teaching tip is a semi-persistent and content-rich flyout ' + 'that provides contextual information. It is often used for ' + 'informing, reminding, and teaching users about important and new ' + 'features that may enhance their experience.', + ), + ), + subtitle( + content: const Text('Show a non-targeted TeachingTip with buttons'), + ), + CardHighlight( + child: Row(children: [ + FlyoutTarget( + controller: nonTargetedController, + child: Button( + child: const Text('Show TeachingTip'), + onPressed: () { + showTeachingTip( + flyoutController: nonTargetedController, + nonTargetedAlignment: alignments[alignment], + placementMode: placements[alignment]!, + builder: (context) => TeachingTip( + title: const Text('Change themes without hassle'), + subtitle: const Text( + 'It\'s easier to see control samples in both light and dark theme', + ), + buttons: [ + Button( + child: const Text('Toggle theme now'), + onPressed: () { + if (theme.brightness.isDark) { + appTheme.mode = ThemeMode.light; + } else { + appTheme.mode = ThemeMode.dark; + } + Navigator.of(context).pop(); + }, + ), + Button( + child: const Text('Got it'), + onPressed: Navigator.of(context).pop, + ), + ], + ), + ); + }, + ), + ), + const SizedBox(width: 18.0), + SizedBox( + width: 150.0, + child: ComboBox( + placeholder: const Text('Alignment'), + items: List.generate(alignments.length, (index) { + final entry = alignments.entries.elementAt(index); + + return ComboBoxItem( + value: entry.key, + child: Text(entry.key.uppercaseFirst()), + ); + }), + value: alignment, + onChanged: (a) { + if (a != null) setState(() => alignment = a); + }, + ), + ), + ]), + codeSnippet: '''final teachingTip = TeachingTip( + title: Text('Change themes without hassle'), + subtitle: Text( + 'It's easier to see control samples in both light and dark theme', + ), + buttons: [ + Button( + child: const Text('Toggle theme now'), + onPressed: () { + // toggle theme here + + // then close the popup + Navigator.of(context).pop(); + }, + ), + Button( + child: const Text('Got it'), + onPressed: Navigator.of(context).pop, + ), + ], +), + +showTeachingTip( + context: context, + teachingTip: teachingTip, +);''', + ), +// subtitle( +// content: const Text('Show a targeted TeachingTip'), +// ), +// CardHighlight( +// child: Row( +// children: [ +// Button( +// child: const Text('Show TeachingTip'), +// onPressed: () { +// targetKey.currentState?.showTeachingTip(builder: (context) { +// return const TeachingTip( +// alignment: Alignment.bottomCenter, +// placementMargin: EdgeInsets.all(20.0), +// title: Text('Change themes without hassle'), +// subtitle: Text( +// 'It\'s easier to see control samples in both light and dark theme', +// ), +// ); +// }); +// }, +// ), +// const Spacer(), +// TeachingTipTarget( +// key: targetKey, +// child: Container( +// height: 100, +// width: 200, +// color: theme.accentColor.defaultBrushFor(theme.brightness), +// ), +// ), +// ], +// ), +// codeSnippet: '''final teachingTip = TeachingTip( +// title: Text('Change themes without hassle'), +// subtitle: Text( +// 'It's easier to see control samples in both light and dark theme', +// ), +// buttons: [ +// Button( +// child: const Text('Toggle theme now'), +// onPressed: () { +// // toggle theme here + +// // then close the popup +// Navigator.of(context).pop(); +// }, +// ), +// Button( +// child: const Text('Got it'), +// onPressed: Navigator.of(context).pop, +// ), +// ], +// ), + +// showTeachingTip( +// context: context, +// teachingTip: teachingTip, +// );''', +// ), + ], + ); + } +} diff --git a/lib/fluent_ui.dart b/lib/fluent_ui.dart index 19f950597..3b0d093b8 100644 --- a/lib/fluent_ui.dart +++ b/lib/fluent_ui.dart @@ -80,6 +80,7 @@ export 'src/controls/surfaces/info_bar.dart'; export 'src/controls/surfaces/list_tile.dart'; export 'src/controls/surfaces/progress_indicators.dart'; export 'src/controls/surfaces/snackbar.dart'; +export 'src/controls/surfaces/teaching_tip.dart'; export 'src/controls/surfaces/tooltip.dart'; export 'src/controls/utils/divider.dart'; export 'src/controls/utils/hover_button.dart'; diff --git a/lib/src/controls/flyouts/flyout.dart b/lib/src/controls/flyouts/flyout.dart index a45ffa46d..8e93039e6 100644 --- a/lib/src/controls/flyouts/flyout.dart +++ b/lib/src/controls/flyouts/flyout.dart @@ -351,10 +351,12 @@ class _FlyoutPositionDelegate extends SingleChildLayoutDelegate { double clampHorizontal(double x) { if (!shouldConstrainToRootBounds) return x; + final max = rootSize.width - flyoutSize.width - margin; + return clampDouble( x, - margin, - rootSize.width - flyoutSize.width - margin, + clampDouble(margin, double.negativeInfinity, max), + max, ); } diff --git a/lib/src/controls/surfaces/dialog.dart b/lib/src/controls/surfaces/dialog.dart index 4c5197022..0bbc88d1d 100644 --- a/lib/src/controls/surfaces/dialog.dart +++ b/lib/src/controls/surfaces/dialog.dart @@ -161,8 +161,8 @@ class ContentDialog extends StatelessWidget { } } -/// Displays a Material dialog above the current contents of the app, with -/// Material entrance and exit animations, modal barrier color, and modal +/// Displays a Fluent dialog above the current contents of the app, with +/// Fluent entrance and exit animations, modal barrier color, and modal /// barrier behavior (dialog is dismissible with a tap on the barrier). /// /// This function takes a `builder` which typically builds a [Dialog] widget. @@ -262,7 +262,7 @@ Future showDialog({ /// onto the [Navigator] stack to enable state restoration. See /// [showDialog] for a state restoration app example. /// -/// This function takes a `builder` which typically builds a [Dialog] widget. +/// This function takes a `builder` which typically builds a [ContentDialog] widget. /// Content below the dialog is dimmed with a [ModalBarrier]. The widget /// returned by the `builder` does not share a context with the location that /// `showDialog` is originally called from. Use a [StatefulBuilder] or a @@ -294,7 +294,7 @@ Future showDialog({ /// * [showDialog], which is a way to display a DialogRoute. /// * [showGeneralDialog], which allows for customization of the dialog popup. class FluentDialogRoute extends RawDialogRoute { - /// A dialog route with Material entrance and exit animations, + /// A dialog route with Fluent entrance and exit animations, /// modal barrier color FluentDialogRoute({ required WidgetBuilder builder, diff --git a/lib/src/controls/surfaces/teaching_tip.dart b/lib/src/controls/surfaces/teaching_tip.dart new file mode 100644 index 000000000..87d4fb0d3 --- /dev/null +++ b/lib/src/controls/surfaces/teaching_tip.dart @@ -0,0 +1,270 @@ +import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +const kTeachingTipConstraints = BoxConstraints( + minHeight: 40.0, + maxHeight: 520.0, + minWidth: 320.0, + maxWidth: 336.0, +); + +/// Displays a Fluent teaching tip at the desired position, with Fluent entrance +/// and exit animations, modal barrier color, and modal barrier behavior +/// (dialog is dismissible with a tap on the barrier). +/// +/// This function takes a `teachingTip`, which typically builds a [TeachingTip] +/// +/// The `context` argument is used to look up the [Navigator] and [FluentTheme] for +/// the dialog. It is only used when the method is called. Its corresponding +/// widget can be safely removed from the tree before the dialog is closed. +/// +/// The `barrierDismissible` argument is used to indicate whether tapping on the +/// barrier will dismiss the dialog. It is `true` by default and can not be `null`. +/// +/// The `barrierColor` argument is used to specify the color of the modal +/// barrier that darkens everything below the dialog. If `null` the default color +/// `Colors.black54` is used. +/// +/// The `useSafeArea` argument is used to indicate if the dialog should only +/// display in 'safe' areas of the screen not used by the operating system +/// (see [SafeArea] for more details). It is `true` by default, which means +/// the dialog will not overlap operating system areas. If it is set to `false` +/// the dialog will only be constrained by the screen size. It can not be `null`. +/// +/// The `useRootNavigator` argument is used to determine whether to push the +/// dialog to the [Navigator] furthest from or nearest to the given `context`. +/// By default, `useRootNavigator` is `true` and the dialog route created by +/// this method is pushed to the root navigator. It can not be `null`. +/// +/// The `routeSettings` argument is passed to [showGeneralDialog], +/// see [RouteSettings] for details. +/// +/// If the application has multiple [Navigator] objects, it may be necessary to +/// call `Navigator.of(context, rootNavigator: true).pop(result)` to close the +/// dialog rather than just `Navigator.pop(context, result)`. +/// +/// Returns a [Future] that resolves to the value (if any) that was passed to +/// [Navigator.pop] when the dialog was closed. +/// +/// ### State Restoration in Popups +/// +/// Using this method will not enable state restoration for the dialog. In order +/// to enable state restoration for a dialog, use [Navigator.restorablePush] +/// or [Navigator.restorablePushNamed] with [FluentDialogRoute]. +/// +/// For more information about state restoration, see [RestorationManager]. +/// +/// See also: +/// +/// * [ContentDialog], for dialogs that have a row of buttons below a body. +/// * [showGeneralDialog], which allows for customization of the dialog popup. +/// * +Future showTeachingTip({ + required WidgetBuilder builder, + required FlyoutController flyoutController, + Alignment? nonTargetedAlignment, + FlyoutPlacementMode placementMode = FlyoutPlacementMode.auto, + Duration? transitionDuration, + FlyoutTransitionBuilder transitionBuilder = + TeachingTip.defaultTransitionBuilder, + Color? barrierColor = Colors.transparent, + bool barrierDismissible = true, +}) { + return flyoutController.showFlyout( + placementMode: placementMode, + position: nonTargetedAlignment != null ? Offset.zero : null, + additionalOffset: 0.0, + transitionDuration: transitionDuration, + transitionBuilder: TeachingTip.defaultTransitionBuilder, + builder: (context) { + final teachingTip = builder(context); + + if (nonTargetedAlignment != null) { + return CustomSingleChildLayout( + delegate: _TeachingTipNonTargetedPositionDelegate( + alignment: nonTargetedAlignment, + ), + child: teachingTip, + ); + } + + return teachingTip; + }, + ); +} + +class _TeachingTipNonTargetedPositionDelegate + extends SingleChildLayoutDelegate { + final Alignment alignment; + + const _TeachingTipNonTargetedPositionDelegate({ + required this.alignment, + }); + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return constraints.loosen(); + } + + @override + Offset getPositionForChild(Size rootSize, Size flyoutSize) { + var pos = alignment.alongSize(rootSize); + + if (alignment.x == 0.0) { + pos = pos - Offset(flyoutSize.width / 2, 0.0); + } + + if (alignment.y == 0.0) { + pos = pos - Offset(0.0, flyoutSize.height / 2); + } + + /// Hardcoded margin because the flyout will always overflow + const margin = 16.0; + + return Offset( + clampDouble(pos.dx, margin, rootSize.width - flyoutSize.width), + clampDouble(pos.dy, 0.0, rootSize.height - flyoutSize.height - margin), + ); + } + + @override + bool shouldRelayout(covariant SingleChildLayoutDelegate oldDelegate) { + return true; + } +} + +/// A teaching tip is a semi-persistent and content-rich flyout that provides +/// contextual information. It is often used for informing, reminding, and +/// teaching users about important and new features that may enhance their +/// experience. +/// +/// A teaching tip may be light-dismiss or require explicit action to close. A +/// teaching tip can target a specific UI element with its tail and also be used +/// without a tail or target. +/// +/// See also: +/// +/// * [ContentDialog], modal UI overlays that provide contextual app information. +/// * [Tooltip], a popup that contains additional information about another object. +/// * +class TeachingTip extends StatelessWidget { + /// Creates a teaching tip + const TeachingTip({ + Key? key, + required this.title, + required this.subtitle, + this.buttons = const [], + }) : super(key: key); + + /// The title of the teaching tip + /// + /// Usually a [Text] + final Widget title; + + /// The subttile of the teaching tip + /// + /// Usually a [Text] + final Widget subtitle; + + final List buttons; + + static Widget defaultTransitionBuilder( + BuildContext context, + Animation animation, + FlyoutPlacementMode placementMode, + Widget flyout, + ) { + late Alignment alignment; + switch (placementMode) { + case FlyoutPlacementMode.bottomCenter: + alignment = const Alignment(0.0, 0.75); + break; + case FlyoutPlacementMode.bottomLeft: + alignment = const Alignment(-0.65, 0.75); + break; + case FlyoutPlacementMode.bottomRight: + alignment = const Alignment(0.75, 0.75); + break; + case FlyoutPlacementMode.topCenter: + alignment = const Alignment(0.0, -0.75); + break; + case FlyoutPlacementMode.topLeft: + alignment = const Alignment(-0.65, -0.75); + break; + case FlyoutPlacementMode.topRight: + alignment = const Alignment(0.75, -0.75); + break; + case FlyoutPlacementMode.left: + case FlyoutPlacementMode.right: + alignment = Alignment.center; + break; + default: + return flyout; + } + + return ScaleTransition( + alignment: alignment, + scale: CurvedAnimation( + curve: Curves.ease, + parent: animation, + ), + child: flyout, + ); + } + + @override + Widget build(BuildContext context) { + assert(debugCheckHasFluentTheme(context)); + final theme = FluentTheme.of(context); + + return ConstrainedBox( + constraints: kTeachingTipConstraints, + child: Acrylic( + elevation: 1.0, + shadowColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(6.0), + side: BorderSide( + color: theme.resources.surfaceStrokeColorDefault, + ), + ), + child: Container( + color: theme.menuColor.withOpacity(0.6), + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: theme.typography.bodyStrong ?? const TextStyle(), + child: title, + ), + subtitle, + if (buttons.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Row( + children: List.generate(buttons.length, (index) { + final isLast = buttons.length - 1 == index; + final button = buttons[index]; + if (isLast) return Expanded(child: button); + return Expanded( + child: Padding( + padding: const EdgeInsetsDirectional.only(end: 6.0), + child: button, + ), + ); + }), + // children: buttons.map((button) { + // return Expanded(child: button); + // }).toList(), + ), + ), + ], + ), + ), + ), + ); + } +}