From 5454bab6c193b850926fa4033aae7b16d3998af7 Mon Sep 17 00:00:00 2001 From: Darren Austin Date: Thu, 25 Aug 2022 11:36:03 -0700 Subject: [PATCH] Added support for M3 filled and filled tonal buttons. (#107382) --- dev/tools/gen_defaults/bin/gen_defaults.dart | 2 + .../material/button_style/button_style.0.dart | 26 +- .../filled_button/filled_button.0.dart | 61 + packages/flutter/lib/material.dart | 2 + packages/flutter/lib/src/material/button.dart | 10 +- .../lib/src/material/button_style.dart | 11 +- .../lib/src/material/button_style_button.dart | 14 +- .../lib/src/material/button_theme.dart | 3 +- .../lib/src/material/elevated_button.dart | 6 +- .../lib/src/material/filled_button.dart | 737 +++++++ .../lib/src/material/filled_button_theme.dart | 128 ++ .../lib/src/material/outlined_button.dart | 6 +- .../flutter/lib/src/material/text_button.dart | 4 +- .../flutter/lib/src/material/theme_data.dart | 18 +- .../test/material/filled_button_test.dart | 1752 +++++++++++++++++ .../material/filled_button_theme_test.dart | 253 +++ .../test/material/theme_data_test.dart | 5 + 17 files changed, 2993 insertions(+), 45 deletions(-) create mode 100644 examples/api/lib/material/filled_button/filled_button.0.dart create mode 100644 packages/flutter/lib/src/material/filled_button.dart create mode 100644 packages/flutter/lib/src/material/filled_button_theme.dart create mode 100644 packages/flutter/test/material/filled_button_test.dart create mode 100644 packages/flutter/test/material/filled_button_theme_test.dart diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index 7651bfdc9443..4e3d669b54f7 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -103,6 +103,8 @@ Future main(List args) async { AppBarTemplate('AppBar', '$materialLib/app_bar.dart', tokens).updateFile(); ButtonTemplate('md.comp.elevated-button', 'ElevatedButton', '$materialLib/elevated_button.dart', tokens).updateFile(); + ButtonTemplate('md.comp.filled-button', 'FilledButton', '$materialLib/filled_button.dart', tokens).updateFile(); + ButtonTemplate('md.comp.filled-tonal-button', 'FilledTonalButton', '$materialLib/filled_button.dart', tokens).updateFile(); ButtonTemplate('md.comp.outlined-button', 'OutlinedButton', '$materialLib/outlined_button.dart', tokens).updateFile(); ButtonTemplate('md.comp.text-button', 'TextButton', '$materialLib/text_button.dart', tokens).updateFile(); CardTemplate('Card', '$materialLib/card.dart', tokens).updateFile(); diff --git a/examples/api/lib/material/button_style/button_style.0.dart b/examples/api/lib/material/button_style/button_style.0.dart index 7f40de2cc000..c77fdd207c6f 100644 --- a/examples/api/lib/material/button_style/button_style.0.dart +++ b/examples/api/lib/material/button_style/button_style.0.dart @@ -58,31 +58,9 @@ class ButtonTypesGroup extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ElevatedButton(onPressed: onPressed, child: const Text('Elevated')), - - // Use an ElevatedButton with specific style to implement the - // 'Filled' type. - ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onPrimary, - backgroundColor: Theme.of(context).colorScheme.primary, - ).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)), - onPressed: onPressed, - child: const Text('Filled'), - ), - - // Use an ElevatedButton with specific style to implement the - // 'Filled Tonal' type. - ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onSecondaryContainer, - backgroundColor: Theme.of(context).colorScheme.secondaryContainer, - ).copyWith(elevation: ButtonStyleButton.allOrNull(0.0)), - onPressed: onPressed, - child: const Text('Filled Tonal'), - ), - + FilledButton(onPressed: onPressed, child: const Text('Filled')), + FilledButton.tonal(onPressed: onPressed, child: const Text('Filled Tonal')), OutlinedButton(onPressed: onPressed, child: const Text('Outlined')), - TextButton(onPressed: onPressed, child: const Text('Text')), ], ), diff --git a/examples/api/lib/material/filled_button/filled_button.0.dart b/examples/api/lib/material/filled_button/filled_button.0.dart new file mode 100644 index 000000000000..5c58f79ef406 --- /dev/null +++ b/examples/api/lib/material/filled_button/filled_button.0.dart @@ -0,0 +1,61 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// Flutter code sample for FilledButton + +import 'package:flutter/material.dart'; + +void main() { + runApp(const FilledButtonApp()); +} + +class FilledButtonApp extends StatelessWidget { + const FilledButtonApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData(colorSchemeSeed: const Color(0xff6750a4), useMaterial3: true), + home: Scaffold( + appBar: AppBar(title: const Text('FilledButton Sample')), + body: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Column(children: [ + const SizedBox(height: 30), + const Text('Filled'), + const SizedBox(height: 15), + FilledButton( + onPressed: () {}, + child: const Text('Enabled'), + ), + const SizedBox(height: 30), + const FilledButton( + onPressed: null, + child: Text('Disabled'), + ), + ]), + const SizedBox(width: 30), + Column(children: [ + const SizedBox(height: 30), + const Text('Filled tonal'), + const SizedBox(height: 15), + FilledButton.tonal( + onPressed: () {}, + child: const Text('Enabled'), + ), + const SizedBox(height: 30), + const FilledButton.tonal( + onPressed: null, + child: Text('Disabled'), + ), + ]) + ], + ), + ), + ), + ); + } +} diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 88edac4bb2d7..5e84a604a6e2 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -80,6 +80,8 @@ export 'src/material/expansion_panel.dart'; export 'src/material/expansion_tile.dart'; export 'src/material/expansion_tile_theme.dart'; export 'src/material/feedback.dart'; +export 'src/material/filled_button.dart'; +export 'src/material/filled_button_theme.dart'; export 'src/material/filter_chip.dart'; export 'src/material/flexible_space_bar.dart'; export 'src/material/floating_action_button.dart'; diff --git a/packages/flutter/lib/src/material/button.dart b/packages/flutter/lib/src/material/button.dart index 9ee96c048d17..5c9a4aeb5468 100644 --- a/packages/flutter/lib/src/material/button.dart +++ b/packages/flutter/lib/src/material/button.dart @@ -26,14 +26,16 @@ import 'theme_data.dart'; /// from the themes or from app-specific sources. /// /// This class is planned to be deprecated in a future release, see -/// [ButtonStyleButton], the base class of [TextButton], [ElevatedButton], and -/// [OutlinedButton]. +/// [ButtonStyleButton], the base class of [ElevatedButton], [FilledButton], +/// [OutlinedButton] and [TextButton]. /// /// See also: /// -/// * [TextButton], a simple flat button without a shadow. /// * [ElevatedButton], a filled button whose material elevates when pressed. -/// * [OutlinedButton], a [TextButton] with a border outline. +/// * [FilledButton], a filled button that doesn't elevate when pressed. +/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color. +/// * [OutlinedButton], a button with an outlined border and no fill color. +/// * [TextButton], a button with no outline or fill color. @Category(['Material', 'Button']) class RawMaterialButton extends StatefulWidget { /// Create a button based on [Semantics], [Material], and [InkWell] widgets. diff --git a/packages/flutter/lib/src/material/button_style.dart b/packages/flutter/lib/src/material/button_style.dart index 6e90614be59e..ec5a70657fac 100644 --- a/packages/flutter/lib/src/material/button_style.dart +++ b/packages/flutter/lib/src/material/button_style.dart @@ -78,8 +78,8 @@ import 'theme_data.dart'; /// useful to make relatively sweeping changes based on a few initial /// parameters with simple values. The button styleFrom() methods /// enable such sweeping changes. See for example: -/// [TextButton.styleFrom], [ElevatedButton.styleFrom], -/// [OutlinedButton.styleFrom]. +/// [ElevatedButton.styleFrom], [FilledButton.styleFrom], +/// [OutlinedButton.styleFrom], [TextButton.styleFrom]. /// /// For example, to override the default text and icon colors for a /// [TextButton], as well as its overlay color, with all of the @@ -119,8 +119,8 @@ import 'theme_data.dart'; /// | Type | Flutter implementation | /// | :----------- | :---------------------- | /// | Elevated | [ElevatedButton] | -/// | Filled | Styled [ElevatedButton] | -/// | Filled Tonal | Styled [ElevatedButton] | +/// | Filled | [FilledButton] | +/// | Filled Tonal | [FilledButton.tonal] | /// | Outlined | [OutlinedButton] | /// | Text | [TextButton] | /// @@ -132,9 +132,10 @@ import 'theme_data.dart'; /// /// See also: /// -/// * [TextButtonTheme], the theme for [TextButton]s. /// * [ElevatedButtonTheme], the theme for [ElevatedButton]s. +/// * [FilledButtonTheme], the theme for [FilledButton]s. /// * [OutlinedButtonTheme], the theme for [OutlinedButton]s. +/// * [TextButtonTheme], the theme for [TextButton]s. @immutable class ButtonStyle with Diagnosticable { /// Create a [ButtonStyle]. diff --git a/packages/flutter/lib/src/material/button_style_button.dart b/packages/flutter/lib/src/material/button_style_button.dart index 3dd671beb388..8bfb967d84ad 100644 --- a/packages/flutter/lib/src/material/button_style_button.dart +++ b/packages/flutter/lib/src/material/button_style_button.dart @@ -21,10 +21,13 @@ import 'theme_data.dart'; /// Concrete subclasses must override [defaultStyleOf] and [themeStyleOf]. /// /// See also: -/// -/// * [TextButton], a simple ButtonStyleButton without a shadow. -/// * [ElevatedButton], a filled ButtonStyleButton whose material elevates when pressed. -/// * [OutlinedButton], similar to [TextButton], but with an outline. +/// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [FilledButton], a filled button that doesn't elevate when pressed. +/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color. +/// * [OutlinedButton], a button with an outlined border and no fill color. +/// * [TextButton], a button with no outline or fill color. +/// * , an overview of each of +/// the Material Design button types and how they should be used in designs. abstract class ButtonStyleButton extends StatefulWidget { /// Abstract const constructor. This constructor enables subclasses to provide /// const constructors so that they can be used in const expressions. @@ -191,9 +194,10 @@ abstract class ButtonStyleButton extends StatefulWidget { /// See also: /// /// * [ButtonStyleButton], the [StatefulWidget] subclass for which this class is the [State]. -/// * [TextButton], a simple button without a shadow. /// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [FilledButton], a filled ButtonStyleButton that doesn't elevate when pressed. /// * [OutlinedButton], similar to [TextButton], but with an outline. +/// * [TextButton], a simple button without a shadow. class _ButtonStyleState extends State with TickerProviderStateMixin { AnimationController? controller; double? elevation; diff --git a/packages/flutter/lib/src/material/button_theme.dart b/packages/flutter/lib/src/material/button_theme.dart index 0f734abbd5f8..4981926f409a 100644 --- a/packages/flutter/lib/src/material/button_theme.dart +++ b/packages/flutter/lib/src/material/button_theme.dart @@ -48,9 +48,10 @@ enum ButtonBarLayoutBehavior { /// This class is planned to be deprecated in a future release. /// Please use one or more of these buttons and associated themes instead: /// -/// * [TextButton], [TextButtonTheme], [TextButtonThemeData], /// * [ElevatedButton], [ElevatedButtonTheme], [ElevatedButtonThemeData], +/// * [FilledButton], [FilledButtonTheme], [FilledButtonThemeData], /// * [OutlinedButton], [OutlinedButtonTheme], [OutlinedButtonThemeData] +/// * [TextButton], [TextButtonTheme], [TextButtonThemeData], /// /// A button theme can be specified as part of the overall Material theme /// using [ThemeData.buttonTheme]. The Material theme's button theme data diff --git a/packages/flutter/lib/src/material/elevated_button.dart b/packages/flutter/lib/src/material/elevated_button.dart index 8d9f46252f66..0efc4da7ef47 100644 --- a/packages/flutter/lib/src/material/elevated_button.dart +++ b/packages/flutter/lib/src/material/elevated_button.dart @@ -54,8 +54,10 @@ import 'theme_data.dart'; /// /// See also: /// -/// * [TextButton], a simple flat button without a shadow. -/// * [OutlinedButton], a [TextButton] with a border outline. +/// * [FilledButton], a filled button that doesn't elevate when pressed. +/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color. +/// * [OutlinedButton], a button with an outlined border and no fill color. +/// * [TextButton], a button with no outline or fill color. /// * /// * class ElevatedButton extends ButtonStyleButton { diff --git a/packages/flutter/lib/src/material/filled_button.dart b/packages/flutter/lib/src/material/filled_button.dart new file mode 100644 index 000000000000..989e7ca222d1 --- /dev/null +++ b/packages/flutter/lib/src/material/filled_button.dart @@ -0,0 +1,737 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'button_style_button.dart'; +import 'color_scheme.dart'; +import 'constants.dart'; +import 'filled_button_theme.dart'; +import 'ink_well.dart'; +import 'material_state.dart'; +import 'theme.dart'; +import 'theme_data.dart'; + +enum _FilledButtonVariant { filled, tonal } + +/// A Material Design filled button. +/// +/// Filled buttons have the most visual impact after the [FloatingActionButton], +/// and should be used for important, final actions that complete a flow, +/// like **Save**, **Join now**, or **Confirm**. +/// +/// A filled button is a label [child] displayed on a [Material] +/// widget. The label's [Text] and [Icon] widgets are displayed in +/// [style]'s [ButtonStyle.foregroundColor] and the button's filled +/// background is the [ButtonStyle.backgroundColor]. +/// +/// The filled button's default style is defined by +/// [defaultStyleOf]. The style of this filled button can be +/// overridden with its [style] parameter. The style of all filled +/// buttons in a subtree can be overridden with the +/// [FilledButtonTheme], and the style of all of the filled +/// buttons in an app can be overridden with the [Theme]'s +/// [ThemeData.filledButtonTheme] property. +/// +/// The static [styleFrom] method is a convenient way to create a +/// filled button [ButtonStyle] from simple values. +/// +/// If [onPressed] and [onLongPress] callbacks are null, then the +/// button will be disabled. +/// +/// To create a 'filled tonal' button, use [FilledButton.tonal]. +/// +/// {@tool dartpad} +/// This sample produces enabled and disabled filled and filled tonal +/// buttons. +/// +/// ** See code in examples/api/lib/material/filled_button/filled_button.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [OutlinedButton], a button with an outlined border and no fill color. +/// * [TextButton], a button with no outline or fill color. +/// * +/// * +class FilledButton extends ButtonStyleButton { + /// Create a FilledButton. + /// + /// The [autofocus] and [clipBehavior] arguments must not be null. + const FilledButton({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior = Clip.none, + super.statesController, + required super.child, + }) : _variant = _FilledButtonVariant.filled; + + /// Create a filled button from [icon] and [label]. + /// + /// The icon and label are arranged in a row with padding at the start and end + /// and a gap between them. + /// + /// The [icon] and [label] arguments must not be null. + factory FilledButton.icon({ + Key? key, + required VoidCallback? onPressed, + VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, + ButtonStyle? style, + FocusNode? focusNode, + bool? autofocus, + Clip? clipBehavior, + required Widget icon, + required Widget label, + }) = _FilledButtonWithIcon; + + /// Create a tonal variant of FilledButton. + /// + /// A filled tonal button is an alternative middle ground between + /// [FilledButton] and [OutlinedButton]. They’re useful in contexts where + /// a lower-priority button requires slightly more emphasis than an + /// outline would give, such as "Next" in an onboarding flow. + /// + /// The [autofocus] and [clipBehavior] arguments must not be null. + const FilledButton.tonal({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + super.autofocus = false, + super.clipBehavior = Clip.none, + super.statesController, + required super.child, + }) : _variant = _FilledButtonVariant.tonal; + + /// Create a filled tonal button from [icon] and [label]. + /// + /// The icon and label are arranged in a row with padding at the start and end + /// and a gap between them. + /// + /// The [icon] and [label] arguments must not be null. + factory FilledButton.tonalIcon({ + Key? key, + required VoidCallback? onPressed, + VoidCallback? onLongPress, + ValueChanged? onHover, + ValueChanged? onFocusChange, + ButtonStyle? style, + FocusNode? focusNode, + bool? autofocus, + Clip? clipBehavior, + required Widget icon, + required Widget label, + }) { + return _FilledButtonWithIcon.tonal( + key: key, + onPressed: onPressed, + onLongPress: onLongPress, + onHover: onHover, + onFocusChange: onFocusChange, + style: style, + focusNode: focusNode, + autofocus: autofocus, + clipBehavior: clipBehavior, + icon: icon, + label: label, + ); + } + + /// A static convenience method that constructs a filled button + /// [ButtonStyle] given simple values. + /// + /// The [foregroundColor], and [disabledForegroundColor] colors are used to create a + /// [MaterialStateProperty] [ButtonStyle.foregroundColor] value. The + /// [backgroundColor] and [disabledBackgroundColor] are used to create a + /// [MaterialStateProperty] [ButtonStyle.backgroundColor] value. + /// + /// The button's elevations are defined relative to the [elevation] + /// parameter. The disabled elevation is the same as the parameter + /// value, [elevation] + 2 is used when the button is hovered + /// or focused, and elevation + 6 is used when the button is pressed. + /// + /// Similarly, the [enabledMouseCursor] and [disabledMouseCursor] + /// parameters are used to construct [ButtonStyle.mouseCursor]. + /// + /// All of the other parameters are either used directly or used to + /// create a [MaterialStateProperty] with a single value for all + /// states. + /// + /// All parameters default to null, by default this method returns + /// a [ButtonStyle] that doesn't override anything. + /// + /// For example, to override the default text and icon colors for a + /// [FilledButton], as well as its overlay color, with all of the + /// standard opacity adjustments for the pressed, focused, and + /// hovered states, one could write: + /// + /// ```dart + /// FilledButton( + /// style: FilledButton.styleFrom(foregroundColor: Colors.green), + /// onPressed: () {}, + /// child: const Text('Filled button'), + /// ); + /// ``` + /// + /// or for a Filled tonal variant: + /// ```dart + /// FilledButton.tonal( + /// style: FilledButton.styleFrom(foregroundColor: Colors.green), + /// onPressed: () {}, + /// child: const Text('Filled tonal button'), + /// ); + /// ``` + static ButtonStyle styleFrom({ + Color? foregroundColor, + Color? backgroundColor, + Color? disabledForegroundColor, + Color? disabledBackgroundColor, + Color? shadowColor, + Color? surfaceTintColor, + double? elevation, + TextStyle? textStyle, + EdgeInsetsGeometry? padding, + Size? minimumSize, + Size? fixedSize, + Size? maximumSize, + BorderSide? side, + OutlinedBorder? shape, + MouseCursor? enabledMouseCursor, + MouseCursor? disabledMouseCursor, + VisualDensity? visualDensity, + MaterialTapTargetSize? tapTargetSize, + Duration? animationDuration, + bool? enableFeedback, + AlignmentGeometry? alignment, + InteractiveInkFeatureFactory? splashFactory, + }) { + final MaterialStateProperty? backgroundColorProp = + (backgroundColor == null && disabledBackgroundColor == null) + ? null + : _FilledButtonDefaultColor(backgroundColor, disabledBackgroundColor); + final Color? foreground = foregroundColor; + final Color? disabledForeground = disabledForegroundColor; + final MaterialStateProperty? foregroundColorProp = + (foreground == null && disabledForeground == null) + ? null + : _FilledButtonDefaultColor(foreground, disabledForeground); + final MaterialStateProperty? overlayColor = (foreground == null) + ? null + : _FilledButtonDefaultOverlay(foreground); + final MaterialStateProperty? mouseCursor = + (enabledMouseCursor == null && disabledMouseCursor == null) + ? null + : _FilledButtonDefaultMouseCursor(enabledMouseCursor, disabledMouseCursor); + + return ButtonStyle( + textStyle: MaterialStatePropertyAll(textStyle), + backgroundColor: backgroundColorProp, + foregroundColor: foregroundColorProp, + overlayColor: overlayColor, + shadowColor: ButtonStyleButton.allOrNull(shadowColor), + surfaceTintColor: ButtonStyleButton.allOrNull(surfaceTintColor), + elevation: ButtonStyleButton.allOrNull(elevation), + padding: ButtonStyleButton.allOrNull(padding), + minimumSize: ButtonStyleButton.allOrNull(minimumSize), + fixedSize: ButtonStyleButton.allOrNull(fixedSize), + maximumSize: ButtonStyleButton.allOrNull(maximumSize), + side: ButtonStyleButton.allOrNull(side), + shape: ButtonStyleButton.allOrNull(shape), + mouseCursor: mouseCursor, + visualDensity: visualDensity, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + splashFactory: splashFactory, + ); + } + + final _FilledButtonVariant _variant; + + /// Defines the button's default appearance. + /// + /// The button [child]'s [Text] and [Icon] widgets are rendered with + /// the [ButtonStyle]'s foreground color. The button's [InkWell] adds + /// the style's overlay color when the button is focused, hovered + /// or pressed. The button's background color becomes its [Material] + /// color. + /// + /// All of the ButtonStyle's defaults appear below. In this list + /// "Theme.foo" is shorthand for `Theme.of(context).foo`. Color + /// scheme values like "onSurface(0.38)" are shorthand for + /// `onSurface.withOpacity(0.38)`. [MaterialStateProperty] valued + /// properties that are not followed by a sublist have the same + /// value for all states, otherwise the values are as specified for + /// each state, and "others" means all other states. + /// + /// The `textScaleFactor` is the value of + /// `MediaQuery.of(context).textScaleFactor` and the names of the + /// EdgeInsets constructors and `EdgeInsetsGeometry.lerp` have been + /// abbreviated for readability. + /// + /// The color of the [ButtonStyle.textStyle] is not used, the + /// [ButtonStyle.foregroundColor] color is used instead. + /// + /// * `textStyle` - Theme.textTheme.labelLarge + /// * `backgroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.12) + /// * others - Theme.colorScheme.secondaryContainer + /// * `foregroundColor` + /// * disabled - Theme.colorScheme.onSurface(0.38) + /// * others - Theme.colorScheme.onSecondaryContainer + /// * `overlayColor` + /// * hovered - Theme.colorScheme.onSecondaryContainer(0.08) + /// * focused or pressed - Theme.colorScheme.onSecondaryContainer(0.12) + /// * `shadowColor` - Theme.colorScheme.shadow + /// * `surfaceTintColor` - null + /// * `elevation` + /// * disabled - 0 + /// * default - 0 + /// * hovered - 1 + /// * focused or pressed - 0 + /// * `padding` + /// * `textScaleFactor <= 1` - horizontal(16) + /// * `1 < textScaleFactor <= 2` - lerp(horizontal(16), horizontal(8)) + /// * `2 < textScaleFactor <= 3` - lerp(horizontal(8), horizontal(4)) + /// * `3 < textScaleFactor` - horizontal(4) + /// * `minimumSize` - Size(64, 40) + /// * `fixedSize` - null + /// * `maximumSize` - Size.infinite + /// * `side` - null + /// * `shape` - StadiumBorder() + /// * `mouseCursor` + /// * disabled - SystemMouseCursors.basic + /// * others - SystemMouseCursors.click + /// * `visualDensity` - Theme.visualDensity + /// * `tapTargetSize` - Theme.materialTapTargetSize + /// * `animationDuration` - kThemeChangeDuration + /// * `enableFeedback` - true + /// * `alignment` - Alignment.center + /// * `splashFactory` - Theme.splashFactory + /// + /// The default padding values for the [FilledButton.icon] factory are slightly different: + /// + /// * `padding` + /// * `textScaleFactor <= 1` - start(12) end(16) + /// * `1 < textScaleFactor <= 2` - lerp(start(12) end(16), horizontal(8)) + /// * `2 < textScaleFactor <= 3` - lerp(horizontal(8), horizontal(4)) + /// * `3 < textScaleFactor` - horizontal(4) + /// + /// The default value for `side`, which defines the appearance of the button's + /// outline, is null. That means that the outline is defined by the button + /// shape's [OutlinedBorder.side]. Typically the default value of an + /// [OutlinedBorder]'s side is [BorderSide.none], so an outline is not drawn. + /// + @override + ButtonStyle defaultStyleOf(BuildContext context) { + switch (_variant) { + case _FilledButtonVariant.filled: + return _FilledButtonDefaultsM3(context); + case _FilledButtonVariant.tonal: + return _FilledTonalButtonDefaultsM3(context); + } + } + + /// Returns the [FilledButtonThemeData.style] of the closest + /// [FilledButtonTheme] ancestor. + @override + ButtonStyle? themeStyleOf(BuildContext context) { + return FilledButtonTheme.of(context).style; + } +} + +EdgeInsetsGeometry _scaledPadding(BuildContext context) { + return ButtonStyleButton.scaledPadding( + const EdgeInsets.symmetric(horizontal: 16), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsets.symmetric(horizontal: 4), + MediaQuery.maybeOf(context)?.textScaleFactor ?? 1, + ); +} + +@immutable +class _FilledButtonDefaultColor extends MaterialStateProperty with Diagnosticable { + _FilledButtonDefaultColor(this.color, this.disabled); + + final Color? color; + final Color? disabled; + + @override + Color? resolve(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabled; + } + return color; + } +} + +@immutable +class _FilledButtonDefaultOverlay extends MaterialStateProperty with Diagnosticable { + _FilledButtonDefaultOverlay(this.overlay); + + final Color overlay; + + @override + Color? resolve(Set states) { + if (states.contains(MaterialState.hovered)) { + return overlay.withOpacity(0.08); + } + if (states.contains(MaterialState.focused) || states.contains(MaterialState.pressed)) { + return overlay.withOpacity(0.12); + } + return null; + } +} + +@immutable +class _FilledButtonDefaultMouseCursor extends MaterialStateProperty with Diagnosticable { + _FilledButtonDefaultMouseCursor(this.enabledCursor, this.disabledCursor); + + final MouseCursor? enabledCursor; + final MouseCursor? disabledCursor; + + @override + MouseCursor? resolve(Set states) { + if (states.contains(MaterialState.disabled)) { + return disabledCursor; + } + return enabledCursor; + } +} + +class _FilledButtonWithIcon extends FilledButton { + _FilledButtonWithIcon({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + bool? autofocus, + Clip? clipBehavior, + required Widget icon, + required Widget label, + }) : assert(icon != null), + assert(label != null), + super( + autofocus: autofocus ?? false, + clipBehavior: clipBehavior ?? Clip.none, + child: _FilledButtonWithIconChild(icon: icon, label: label) + ); + + _FilledButtonWithIcon.tonal({ + super.key, + required super.onPressed, + super.onLongPress, + super.onHover, + super.onFocusChange, + super.style, + super.focusNode, + bool? autofocus, + Clip? clipBehavior, + required Widget icon, + required Widget label, + }) : assert(icon != null), + assert(label != null), + super.tonal( + autofocus: autofocus ?? false, + clipBehavior: clipBehavior ?? Clip.none, + child: _FilledButtonWithIconChild(icon: icon, label: label) + ); + + @override + ButtonStyle defaultStyleOf(BuildContext context) { + final EdgeInsetsGeometry scaledPadding = ButtonStyleButton.scaledPadding( + const EdgeInsetsDirectional.fromSTEB(12, 0, 16, 0), + const EdgeInsets.symmetric(horizontal: 8), + const EdgeInsetsDirectional.fromSTEB(8, 0, 4, 0), + MediaQuery.maybeOf(context)?.textScaleFactor ?? 1, + ); + return super.defaultStyleOf(context).copyWith( + padding: MaterialStatePropertyAll(scaledPadding), + ); + } +} + +class _FilledButtonWithIconChild extends StatelessWidget { + const _FilledButtonWithIconChild({ required this.label, required this.icon }); + + final Widget label; + final Widget icon; + + @override + Widget build(BuildContext context) { + final double scale = MediaQuery.maybeOf(context)?.textScaleFactor ?? 1; + // Adjust the gap based on the text scale factor. Start at 8, and lerp + // to 4 based on how large the text is. + final double gap = scale <= 1 ? 8 : lerpDouble(8, 4, math.min(scale - 1, 1))!; + return Row( + mainAxisSize: MainAxisSize.min, + children: [icon, SizedBox(width: gap), Flexible(child: label)], + ); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - FilledButton + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_101 + +class _FilledButtonDefaultsM3 extends ButtonStyle { + _FilledButtonDefaultsM3(this.context) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + MaterialStateProperty get textStyle => + MaterialStatePropertyAll(Theme.of(context).textTheme.labelLarge); + + @override + MaterialStateProperty? get backgroundColor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.primary; + }); + + @override + MaterialStateProperty? get foregroundColor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onPrimary; + }); + + @override + MaterialStateProperty? get overlayColor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.hovered)) { + return _colors.onPrimary.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onPrimary.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.onPrimary.withOpacity(0.12); + } + return null; + }); + + @override + MaterialStateProperty? get shadowColor => + ButtonStyleButton.allOrNull(_colors.shadow); + + // No default surface tint color + + @override + MaterialStateProperty? get elevation => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return 0.0; + } + if (states.contains(MaterialState.hovered)) { + return 1.0; + } + if (states.contains(MaterialState.focused)) { + return 0.0; + } + if (states.contains(MaterialState.pressed)) { + return 0.0; + } + return 0.0; + }); + + @override + MaterialStateProperty? get padding => + ButtonStyleButton.allOrNull(_scaledPadding(context)); + + @override + MaterialStateProperty? get minimumSize => + ButtonStyleButton.allOrNull(const Size(64.0, 40.0)); + + // No default fixedSize + + @override + MaterialStateProperty? get maximumSize => + ButtonStyleButton.allOrNull(Size.infinite); + + // No default side + + @override + MaterialStateProperty? get shape => + ButtonStyleButton.allOrNull(const StadiumBorder()); + + @override + MaterialStateProperty? get mouseCursor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return SystemMouseCursors.basic; + } + return SystemMouseCursors.click; + }); + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} + +// END GENERATED TOKEN PROPERTIES - FilledButton + +// BEGIN GENERATED TOKEN PROPERTIES - FilledTonalButton + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +// Token database version: v0_101 + +class _FilledTonalButtonDefaultsM3 extends ButtonStyle { + _FilledTonalButtonDefaultsM3(this.context) + : super( + animationDuration: kThemeChangeDuration, + enableFeedback: true, + alignment: Alignment.center, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + MaterialStateProperty get textStyle => + MaterialStatePropertyAll(Theme.of(context).textTheme.labelLarge); + + @override + MaterialStateProperty? get backgroundColor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return _colors.onSurface.withOpacity(0.12); + } + return _colors.secondaryContainer; + }); + + @override + MaterialStateProperty? get foregroundColor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return _colors.onSurface.withOpacity(0.38); + } + return _colors.onSecondaryContainer; + }); + + @override + MaterialStateProperty? get overlayColor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.hovered)) { + return _colors.onSecondaryContainer.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.onSecondaryContainer.withOpacity(0.12); + } + if (states.contains(MaterialState.pressed)) { + return _colors.onSecondaryContainer.withOpacity(0.12); + } + return null; + }); + + @override + MaterialStateProperty? get shadowColor => + ButtonStyleButton.allOrNull(_colors.shadow); + + // No default surface tint color + + @override + MaterialStateProperty? get elevation => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return 0.0; + } + if (states.contains(MaterialState.hovered)) { + return 1.0; + } + if (states.contains(MaterialState.focused)) { + return 0.0; + } + if (states.contains(MaterialState.pressed)) { + return 0.0; + } + return 0.0; + }); + + @override + MaterialStateProperty? get padding => + ButtonStyleButton.allOrNull(_scaledPadding(context)); + + @override + MaterialStateProperty? get minimumSize => + ButtonStyleButton.allOrNull(const Size(64.0, 40.0)); + + // No default fixedSize + + @override + MaterialStateProperty? get maximumSize => + ButtonStyleButton.allOrNull(Size.infinite); + + // No default side + + @override + MaterialStateProperty? get shape => + ButtonStyleButton.allOrNull(const StadiumBorder()); + + @override + MaterialStateProperty? get mouseCursor => + MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return SystemMouseCursors.basic; + } + return SystemMouseCursors.click; + }); + + @override + VisualDensity? get visualDensity => Theme.of(context).visualDensity; + + @override + MaterialTapTargetSize? get tapTargetSize => Theme.of(context).materialTapTargetSize; + + @override + InteractiveInkFeatureFactory? get splashFactory => Theme.of(context).splashFactory; +} + +// END GENERATED TOKEN PROPERTIES - FilledTonalButton diff --git a/packages/flutter/lib/src/material/filled_button_theme.dart b/packages/flutter/lib/src/material/filled_button_theme.dart new file mode 100644 index 000000000000..fa7a6d5d5651 --- /dev/null +++ b/packages/flutter/lib/src/material/filled_button_theme.dart @@ -0,0 +1,128 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'theme.dart'; + +// Examples can assume: +// late BuildContext context; + +/// A [ButtonStyle] that overrides the default appearance of +/// [FilledButton]s when it's used with [FilledButtonTheme] or with the +/// overall [Theme]'s [ThemeData.filledButtonTheme]. +/// +/// The [style]'s properties override [FilledButton]'s default style, +/// i.e. the [ButtonStyle] returned by [FilledButton.defaultStyleOf]. Only +/// the style's non-null property values or resolved non-null +/// [MaterialStateProperty] values are used. +/// +/// See also: +/// +/// * [FilledButtonTheme], the theme which is configured with this class. +/// * [FilledButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for text buttons. +/// * [FilledButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [FilledButton]'s defaults. +/// * [MaterialStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [MaterialState]s. +/// * [ThemeData.filledButtonTheme], which can be used to override the default +/// [ButtonStyle] for [FilledButton]s below the overall [Theme]. +@immutable +class FilledButtonThemeData with Diagnosticable { + /// Creates an [FilledButtonThemeData]. + /// + /// The [style] may be null. + const FilledButtonThemeData({ this.style }); + + /// Overrides for [FilledButton]'s default style. + /// + /// Non-null properties or non-null resolved [MaterialStateProperty] + /// values override the [ButtonStyle] returned by + /// [FilledButton.defaultStyleOf]. + /// + /// If [style] is null, then this theme doesn't override anything. + final ButtonStyle? style; + + /// Linearly interpolate between two filled button themes. + static FilledButtonThemeData? lerp(FilledButtonThemeData? a, FilledButtonThemeData? b, double t) { + assert (t != null); + if (a == null && b == null) { + return null; + } + return FilledButtonThemeData( + style: ButtonStyle.lerp(a?.style, b?.style, t), + ); + } + + @override + int get hashCode => style.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is FilledButtonThemeData && other.style == style; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('style', style, defaultValue: null)); + } +} + +/// Overrides the default [ButtonStyle] of its [FilledButton] descendants. +/// +/// See also: +/// +/// * [FilledButtonThemeData], which is used to configure this theme. +/// * [FilledButton.defaultStyleOf], which returns the default [ButtonStyle] +/// for filled buttons. +/// * [FilledButton.styleFrom], which converts simple values into a +/// [ButtonStyle] that's consistent with [FilledButton]'s defaults. +/// * [ThemeData.filledButtonTheme], which can be used to override the default +/// [ButtonStyle] for [FilledButton]s below the overall [Theme]. +class FilledButtonTheme extends InheritedTheme { + /// Create a [FilledButtonTheme]. + /// + /// The [data] parameter must not be null. + const FilledButtonTheme({ + super.key, + required this.data, + required super.child, + }) : assert(data != null); + + /// The configuration of this theme. + final FilledButtonThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [FilledButtonTheme] widget, then + /// [ThemeData.filledButtonTheme] is used. + /// + /// Typical usage is as follows: + /// + /// ```dart + /// FilledButtonThemeData theme = FilledButtonTheme.of(context); + /// ``` + static FilledButtonThemeData of(BuildContext context) { + final FilledButtonTheme? buttonTheme = context.dependOnInheritedWidgetOfExactType(); + return buttonTheme?.data ?? Theme.of(context).filledButtonTheme; + } + + @override + Widget wrap(BuildContext context, Widget child) { + return FilledButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(FilledButtonTheme oldWidget) => data != oldWidget.data; +} diff --git a/packages/flutter/lib/src/material/outlined_button.dart b/packages/flutter/lib/src/material/outlined_button.dart index 065fc1e5becb..176940a9cb20 100644 --- a/packages/flutter/lib/src/material/outlined_button.dart +++ b/packages/flutter/lib/src/material/outlined_button.dart @@ -59,8 +59,10 @@ import 'theme_data.dart'; /// /// See also: /// -/// * [ElevatedButton], a filled Material Design button with a shadow. -/// * [TextButton], a Material Design button without a shadow. +/// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [FilledButton], a filled button that doesn't elevate when pressed. +/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color. +/// * [TextButton], a button with no outline or fill color. /// * /// * class OutlinedButton extends ButtonStyleButton { diff --git a/packages/flutter/lib/src/material/text_button.dart b/packages/flutter/lib/src/material/text_button.dart index 4035dbf2d756..3f22f26912cf 100644 --- a/packages/flutter/lib/src/material/text_button.dart +++ b/packages/flutter/lib/src/material/text_button.dart @@ -66,8 +66,10 @@ import 'theme_data.dart'; /// /// See also: /// -/// * [OutlinedButton], a [TextButton] with a border outline. /// * [ElevatedButton], a filled button whose material elevates when pressed. +/// * [FilledButton], a filled button that doesn't elevate when pressed. +/// * [FilledButton.tonal], a filled button variant that uses a secondary fill color. +/// * [OutlinedButton], a button with an outlined border and no fill color. /// * /// * class TextButton extends ButtonStyleButton { diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 384520b06dd7..87fc86facac1 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -26,6 +26,7 @@ import 'divider_theme.dart'; import 'drawer_theme.dart'; import 'elevated_button_theme.dart'; import 'expansion_tile_theme.dart'; +import 'filled_button_theme.dart'; import 'floating_action_button_theme.dart'; import 'icon_button_theme.dart'; import 'ink_ripple.dart'; @@ -343,6 +344,7 @@ class ThemeData with Diagnosticable { DrawerThemeData? drawerTheme, ElevatedButtonThemeData? elevatedButtonTheme, ExpansionTileThemeData? expansionTileTheme, + FilledButtonThemeData? filledButtonTheme, FloatingActionButtonThemeData? floatingActionButtonTheme, IconButtonThemeData? iconButtonTheme, ListTileThemeData? listTileTheme, @@ -564,6 +566,7 @@ class ThemeData with Diagnosticable { dividerTheme ??= const DividerThemeData(); drawerTheme ??= const DrawerThemeData(); elevatedButtonTheme ??= const ElevatedButtonThemeData(); + filledButtonTheme ??= const FilledButtonThemeData(); floatingActionButtonTheme ??= const FloatingActionButtonThemeData(); iconButtonTheme ??= const IconButtonThemeData(); listTileTheme ??= const ListTileThemeData(); @@ -655,6 +658,7 @@ class ThemeData with Diagnosticable { drawerTheme: drawerTheme, elevatedButtonTheme: elevatedButtonTheme, expansionTileTheme: expansionTileTheme, + filledButtonTheme: filledButtonTheme, floatingActionButtonTheme: floatingActionButtonTheme, iconButtonTheme: iconButtonTheme, listTileTheme: listTileTheme, @@ -761,6 +765,7 @@ class ThemeData with Diagnosticable { required this.drawerTheme, required this.elevatedButtonTheme, required this.expansionTileTheme, + required this.filledButtonTheme, required this.floatingActionButtonTheme, required this.iconButtonTheme, required this.listTileTheme, @@ -909,6 +914,7 @@ class ThemeData with Diagnosticable { assert(drawerTheme != null), assert(elevatedButtonTheme != null), assert(expansionTileTheme != null), + assert(filledButtonTheme != null), assert(floatingActionButtonTheme != null), assert(iconButtonTheme != null), assert(listTileTheme != null), @@ -1211,7 +1217,7 @@ class ThemeData with Diagnosticable { /// * Typography: `typography` (see table above) /// /// ### Components - /// * Common buttons: [TextButton], [OutlinedButton], [ElevatedButton] + /// * Common buttons: [ElevatedButton], [FilledButton], [OutlinedButton], [TextButton] /// * FAB: [FloatingActionButton] /// * Extended FAB: [FloatingActionButton.extended] /// * Cards: [Card] @@ -1464,6 +1470,10 @@ class ThemeData with Diagnosticable { /// A theme for customizing the visual properties of [ExpansionTile]s. final ExpansionTileThemeData expansionTileTheme; + /// A theme for customizing the appearance and internal layout of + /// [FilledButton]s. + final FilledButtonThemeData filledButtonTheme; + /// A theme for customizing the shape, elevation, and color of a /// [FloatingActionButton]. final FloatingActionButtonThemeData floatingActionButtonTheme; @@ -1739,6 +1749,7 @@ class ThemeData with Diagnosticable { DrawerThemeData? drawerTheme, ElevatedButtonThemeData? elevatedButtonTheme, ExpansionTileThemeData? expansionTileTheme, + FilledButtonThemeData? filledButtonTheme, FloatingActionButtonThemeData? floatingActionButtonTheme, IconButtonThemeData? iconButtonTheme, ListTileThemeData? listTileTheme, @@ -1884,6 +1895,7 @@ class ThemeData with Diagnosticable { drawerTheme: drawerTheme ?? this.drawerTheme, elevatedButtonTheme: elevatedButtonTheme ?? this.elevatedButtonTheme, expansionTileTheme: expansionTileTheme ?? this.expansionTileTheme, + filledButtonTheme: filledButtonTheme ?? this.filledButtonTheme, floatingActionButtonTheme: floatingActionButtonTheme ?? this.floatingActionButtonTheme, iconButtonTheme: iconButtonTheme ?? this.iconButtonTheme, listTileTheme: listTileTheme ?? this.listTileTheme, @@ -2083,6 +2095,7 @@ class ThemeData with Diagnosticable { drawerTheme: DrawerThemeData.lerp(a.drawerTheme, b.drawerTheme, t)!, elevatedButtonTheme: ElevatedButtonThemeData.lerp(a.elevatedButtonTheme, b.elevatedButtonTheme, t)!, expansionTileTheme: ExpansionTileThemeData.lerp(a.expansionTileTheme, b.expansionTileTheme, t)!, + filledButtonTheme: FilledButtonThemeData.lerp(a.filledButtonTheme, b.filledButtonTheme, t)!, floatingActionButtonTheme: FloatingActionButtonThemeData.lerp(a.floatingActionButtonTheme, b.floatingActionButtonTheme, t)!, iconButtonTheme: IconButtonThemeData.lerp(a.iconButtonTheme, b.iconButtonTheme, t)!, listTileTheme: ListTileThemeData.lerp(a.listTileTheme, b.listTileTheme, t)!, @@ -2184,6 +2197,7 @@ class ThemeData with Diagnosticable { other.drawerTheme == drawerTheme && other.elevatedButtonTheme == elevatedButtonTheme && other.expansionTileTheme == expansionTileTheme && + other.filledButtonTheme == filledButtonTheme && other.floatingActionButtonTheme == floatingActionButtonTheme && other.iconButtonTheme == iconButtonTheme && other.listTileTheme == listTileTheme && @@ -2282,6 +2296,7 @@ class ThemeData with Diagnosticable { drawerTheme, elevatedButtonTheme, expansionTileTheme, + filledButtonTheme, floatingActionButtonTheme, iconButtonTheme, listTileTheme, @@ -2382,6 +2397,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('drawerTheme', drawerTheme, defaultValue: defaultData.drawerTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('elevatedButtonTheme', elevatedButtonTheme, defaultValue: defaultData.elevatedButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('expansionTileTheme', expansionTileTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('filledButtonTheme', filledButtonTheme, defaultValue: defaultData.filledButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('floatingActionButtonTheme', floatingActionButtonTheme, defaultValue: defaultData.floatingActionButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('iconButtonTheme', iconButtonTheme, defaultValue: defaultData.iconButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('listTileTheme', listTileTheme, defaultValue: defaultData.listTileTheme, level: DiagnosticLevel.debug)); diff --git a/packages/flutter/test/material/filled_button_test.dart b/packages/flutter/test/material/filled_button_test.dart new file mode 100644 index 000000000000..f91e7a05625c --- /dev/null +++ b/packages/flutter/test/material/filled_button_test.dart @@ -0,0 +1,1752 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../rendering/mock_canvas.dart'; +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('FilledButton, FilledButton.icon defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + final ThemeData theme = ThemeData.from(colorScheme: colorScheme); + + // Enabled FilledButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Material), + ); + + Material material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.onPrimary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget(find.ancestor(of: find.text('button'), matching: find.byType(Align))); + expect(align.alignment, Alignment.center); + + final Offset center = tester.getCenter(find.byType(FilledButton)); + await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + // Enabled FilledButton.icon + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton.icon( + key: iconButtonKey, + onPressed: () { }, + icon: const Icon(Icons.add), + label: const Text('label'), + ), + ), + ), + ); + + final Finder iconButtonMaterial = find.descendant( + of: find.byKey(iconButtonKey), + matching: find.byType(Material), + ); + + material = tester.widget(iconButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.primary); + expect(material.elevation, 0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.onPrimary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled FilledButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: FilledButton( + onPressed: null, + child: Text('button'), + ), + ), + ), + ); + + // Finish the elevation animation, final background color change. + await tester.pumpAndSettle(); + + material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.onSurface.withOpacity(0.12)); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.onSurface.withOpacity(0.38)); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + }); + + testWidgets('FilledButton.tonal, FilledButton.tonalIcon defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + final ThemeData theme = ThemeData.from(colorScheme: colorScheme); + + // Enabled FilledButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton.tonal( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Material), + ); + + Material material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.secondaryContainer); + expect(material.elevation, 0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.onSecondaryContainer); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget(find.ancestor(of: find.text('button'), matching: find.byType(Align))); + expect(align.alignment, Alignment.center); + + final Offset center = tester.getCenter(find.byType(FilledButton)); + await tester.startGesture(center); + await tester.pump(); // start the splash animation + await tester.pump(const Duration(milliseconds: 100)); // splash is underway + + // Enabled FilledButton.tonalIcon + final Key iconButtonKey = UniqueKey(); + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton.tonalIcon( + key: iconButtonKey, + onPressed: () { }, + icon: const Icon(Icons.add), + label: const Text('label'), + ), + ), + ), + ); + + final Finder iconButtonMaterial = find.descendant( + of: find.byKey(iconButtonKey), + matching: find.byType(Material), + ); + + material = tester.widget(iconButtonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.secondaryContainer); + expect(material.elevation, 0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.onSecondaryContainer); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + + // Disabled FilledButton + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: const Center( + child: FilledButton.tonal( + onPressed: null, + child: Text('button'), + ), + ), + ), + ); + + // Finish the elevation animation, final background color change. + await tester.pumpAndSettle(); + + material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderOnForeground, true); + expect(material.borderRadius, null); + expect(material.clipBehavior, Clip.none); + expect(material.color, colorScheme.onSurface.withOpacity(0.12)); + expect(material.elevation, 0.0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.onSurface.withOpacity(0.38)); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + expect(material.type, MaterialType.button); + }); + + testWidgets('Default FilledButton meets a11y contrast guidelines', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Scaffold( + body: Center( + child: FilledButton( + onPressed: () { }, + focusNode: focusNode, + child: const Text('FilledButton'), + ), + ), + ), + ), + ); + + // Default, not disabled. + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + // Hovered. + final Offset center = tester.getCenter(find.byType(FilledButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + }, + skip: isBrowser, // https://github.com/flutter/flutter/issues/44115 + ); + + + testWidgets('FilledButton uses stateful color for text color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + + const Color pressedColor = Color(0x00000001); + const Color hoverColor = Color(0x00000002); + const Color focusedColor = Color(0x00000003); + const Color defaultColor = Color(0x00000004); + + Color getTextColor(Set states) { + if (states.contains(MaterialState.pressed)) { + return pressedColor; + } + if (states.contains(MaterialState.hovered)) { + return hoverColor; + } + if (states.contains(MaterialState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FilledButtonTheme( + data: FilledButtonThemeData( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return FilledButton( + onPressed: () {}, + focusNode: focusNode, + child: const Text('FilledButton'), + ); + }, + ), + ), + ), + ), + ), + ); + + Color textColor() { + return tester.renderObject(find.text('FilledButton')).text.style!.color!; + } + + // Default, not disabled. + expect(textColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(textColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byType(FilledButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(textColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(textColor(), pressedColor); + }); + + + testWidgets('FilledButton uses stateful color for icon color in different states', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(); + final Key buttonKey = UniqueKey(); + + const Color pressedColor = Color(0x00000001); + const Color hoverColor = Color(0x00000002); + const Color focusedColor = Color(0x00000003); + const Color defaultColor = Color(0x00000004); + + Color getTextColor(Set states) { + if (states.contains(MaterialState.pressed)) { + return pressedColor; + } + if (states.contains(MaterialState.hovered)) { + return hoverColor; + } + if (states.contains(MaterialState.focused)) { + return focusedColor; + } + return defaultColor; + } + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FilledButtonTheme( + data: FilledButtonThemeData( + style: ButtonStyle( + foregroundColor: MaterialStateProperty.resolveWith(getTextColor), + ), + ), + child: Builder( + builder: (BuildContext context) { + return FilledButton.icon( + key: buttonKey, + icon: const Icon(Icons.add), + label: const Text('FilledButton'), + onPressed: () {}, + focusNode: focusNode, + ); + }, + ), + ), + ), + ), + ), + ); + + Color iconColor() => _iconStyle(tester, Icons.add).color!; + // Default, not disabled. + expect(iconColor(), equals(defaultColor)); + + // Focused. + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(iconColor(), focusedColor); + + // Hovered. + final Offset center = tester.getCenter(find.byKey(buttonKey)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + expect(iconColor(), hoverColor); + + // Highlighted (pressed). + await gesture.down(center); + await tester.pump(); // Start the splash and highlight animations. + await tester.pump(const Duration(milliseconds: 800)); // Wait for splash and highlight to be well under way. + expect(iconColor(), pressedColor); + }); + + testWidgets('FilledButton onPressed and onLongPress callbacks are correctly called when non-null', (WidgetTester tester) async { + bool wasPressed; + Finder filledButton; + + Widget buildFrame({ VoidCallback? onPressed, VoidCallback? onLongPress }) { + return Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + onPressed: onPressed, + onLongPress: onLongPress, + child: const Text('button'), + ), + ); + } + + // onPressed not null, onLongPress null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onPressed: () { wasPressed = true; }), + ); + filledButton = find.byType(FilledButton); + expect(tester.widget(filledButton).enabled, true); + await tester.tap(filledButton); + expect(wasPressed, true); + + // onPressed null, onLongPress not null. + wasPressed = false; + await tester.pumpWidget( + buildFrame(onLongPress: () { wasPressed = true; }), + ); + filledButton = find.byType(FilledButton); + expect(tester.widget(filledButton).enabled, true); + await tester.longPress(filledButton); + expect(wasPressed, true); + + // onPressed null, onLongPress null. + await tester.pumpWidget( + buildFrame(), + ); + filledButton = find.byType(FilledButton); + expect(tester.widget(filledButton).enabled, false); + }); + + testWidgets('FilledButton onPressed and onLongPress callbacks are distinctly recognized', (WidgetTester tester) async { + bool didPressButton = false; + bool didLongPressButton = false; + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + onPressed: () { + didPressButton = true; + }, + onLongPress: () { + didLongPressButton = true; + }, + child: const Text('button'), + ), + ), + ); + + final Finder filledButton = find.byType(FilledButton); + expect(tester.widget(filledButton).enabled, true); + + expect(didPressButton, isFalse); + await tester.tap(filledButton); + expect(didPressButton, isTrue); + + expect(didLongPressButton, isFalse); + await tester.longPress(filledButton); + expect(didLongPressButton, isTrue); + }); + + testWidgets("FilledButton response doesn't hover when disabled", (WidgetTester tester) async { + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTouch; + final FocusNode focusNode = FocusNode(debugLabel: 'FilledButton Focus'); + final GlobalKey childKey = GlobalKey(); + bool hovering = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: FilledButton( + autofocus: true, + onPressed: () {}, + onLongPress: () {}, + onHover: (bool value) { hovering = value; }, + focusNode: focusNode, + child: SizedBox(key: childKey), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isTrue); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byKey(childKey))); + await tester.pumpAndSettle(); + expect(hovering, isTrue); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 100, + height: 100, + child: FilledButton( + focusNode: focusNode, + onHover: (bool value) { hovering = value; }, + onPressed: null, + child: SizedBox(key: childKey), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + expect(focusNode.hasPrimaryFocus, isFalse); + }); + + testWidgets('disabled and hovered FilledButton responds to mouse-exit', (WidgetTester tester) async { + int onHoverCount = 0; + late bool hover; + + Widget buildFrame({ required bool enabled }) { + return Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 100, + height: 100, + child: FilledButton( + onPressed: enabled ? () { } : null, + onHover: (bool value) { + onHoverCount += 1; + hover = value; + }, + child: const Text('FilledButton'), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildFrame(enabled: true)); + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + + await gesture.moveTo(tester.getCenter(find.byType(FilledButton))); + await tester.pumpAndSettle(); + expect(onHoverCount, 1); + expect(hover, true); + + await tester.pumpWidget(buildFrame(enabled: false)); + await tester.pumpAndSettle(); + await gesture.moveTo(Offset.zero); + // Even though the FilledButton has been disabled, the mouse-exit still + // causes onHover(false) to be called. + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(FilledButton))); + await tester.pumpAndSettle(); + // We no longer see hover events because the FilledButton is disabled + // and it's no longer in the "hovering" state. + expect(onHoverCount, 2); + expect(hover, false); + + await tester.pumpWidget(buildFrame(enabled: true)); + await tester.pumpAndSettle(); + // The FilledButton was enabled while it contained the mouse, however + // we do not call onHover() because it may call setState(). + expect(onHoverCount, 2); + expect(hover, false); + + await gesture.moveTo(tester.getCenter(find.byType(FilledButton)) - const Offset(1, 1)); + await tester.pumpAndSettle(); + // Moving the mouse a little within the FilledButton doesn't change anything. + expect(onHoverCount, 2); + expect(hover, false); + }); + + testWidgets('Can set FilledButton focus and Can set unFocus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'FilledButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: () { }, + child: const SizedBox(), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isTrue); + expect(node.hasFocus, isTrue); + + node.unfocus(); + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + + testWidgets('When FilledButton disable, Can not set FilledButton focus.', (WidgetTester tester) async { + final FocusNode node = FocusNode(debugLabel: 'FilledButton Focus'); + bool gotFocus = false; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + focusNode: node, + onFocusChange: (bool focused) => gotFocus = focused, + onPressed: null, + child: const SizedBox(), + ), + ), + ); + + node.requestFocus(); + + await tester.pump(); + + expect(gotFocus, isFalse); + expect(node.hasFocus, isFalse); + }); + + testWidgets('Does FilledButton work with hover', (WidgetTester tester) async { + const Color hoverColor = Color(0xff001122); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.hovered) ? hoverColor : null; + }), + ), + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(); + await gesture.moveTo(tester.getCenter(find.byType(FilledButton))); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: hoverColor)); + }); + + testWidgets('Does FilledButton work with focus', (WidgetTester tester) async { + const Color focusColor = Color(0xff001122); + + final FocusNode focusNode = FocusNode(debugLabel: 'FilledButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith((Set states) { + return states.contains(MaterialState.focused) ? focusColor : null; + }), + ), + focusNode: focusNode, + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + focusNode.requestFocus(); + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets('Does FilledButton work with autofocus', (WidgetTester tester) async { + const Color focusColor = Color(0xff001122); + + Color? getOverlayColor(Set states) { + return states.contains(MaterialState.focused) ? focusColor : null; + } + + final FocusNode focusNode = FocusNode(debugLabel: 'FilledButton Node'); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + autofocus: true, + style: ButtonStyle( + overlayColor: MaterialStateProperty.resolveWith(getOverlayColor), + ), + focusNode: focusNode, + onPressed: () { }, + child: const Text('button'), + ), + ), + ); + + FocusManager.instance.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + await tester.pumpAndSettle(); + + final RenderObject inkFeatures = tester.allRenderObjects.firstWhere((RenderObject object) => object.runtimeType.toString() == '_RenderInkFeatures'); + expect(inkFeatures, paints..rect(color: focusColor)); + }); + + testWidgets('Does FilledButton contribute semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FilledButton( + style: const ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the semantics tree's rect and transform + // match the original version of this test. + minimumSize: MaterialStatePropertyAll(Size(88, 36)), + ), + onPressed: () { }, + child: const Text('ABC'), + ), + ), + ), + ); + + expect(semantics, hasSemantics( + TestSemantics.root( + children: [ + TestSemantics.rootChild( + actions: [ + SemanticsAction.tap, + ], + label: 'ABC', + rect: const Rect.fromLTRB(0.0, 0.0, 88.0, 48.0), + transform: Matrix4.translationValues(356.0, 276.0, 0.0), + flags: [ + SemanticsFlag.hasEnabledState, + SemanticsFlag.isButton, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + ], + ), + ], + ), + ignoreId: true, + )); + + semantics.dispose(); + }); + + testWidgets('FilledButton size is configurable by ThemeData.materialTapTargetSize', (WidgetTester tester) async { + const ButtonStyle style = ButtonStyle( + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the corresponding button size matches + // the original version of this test. + minimumSize: MaterialStatePropertyAll(Size(88, 36)), + ); + + Widget buildFrame(MaterialTapTargetSize tapTargetSize, Key key) { + return Theme( + data: ThemeData(materialTapTargetSize: tapTargetSize), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FilledButton( + key: key, + style: style, + child: const SizedBox(width: 50.0, height: 8.0), + onPressed: () { }, + ), + ), + ), + ); + } + + final Key key1 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.padded, key1)); + expect(tester.getSize(find.byKey(key1)), const Size(88.0, 48.0)); + + final Key key2 = UniqueKey(); + await tester.pumpWidget(buildFrame(MaterialTapTargetSize.shrinkWrap, key2)); + expect(tester.getSize(find.byKey(key2)), const Size(88.0, 36.0)); + }); + + testWidgets('FilledButton has no clip by default', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FilledButton( + onPressed: () { /* to make sure the button is enabled */ }, + child: const Text('button'), + ), + ), + ); + + expect( + tester.renderObject(find.byType(FilledButton)), + paintsExactlyCountTimes(#clipPath, 0), + ); + }); + + testWidgets('FilledButton responds to density changes.', (WidgetTester tester) async { + const Key key = Key('test'); + const Key childKey = Key('test child'); + + Future buildTest(VisualDensity visualDensity, {bool useText = false}) async { + return tester.pumpWidget( + MaterialApp( + // Test was setup using fonts from Material 2, so make sure we always + // test against englishLike2014. + theme: ThemeData(textTheme: Typography.englishLike2014), + home: Directionality( + textDirection: TextDirection.rtl, + child: Center( + child: FilledButton( + style: ButtonStyle( + visualDensity: visualDensity, + // Specifying minimumSize to mimic the original minimumSize for + // RaisedButton so that the corresponding button size matches + // the original version of this test. + minimumSize: const MaterialStatePropertyAll(Size(88, 36)), + ), + key: key, + onPressed: () {}, + child: useText + ? const Text('Text', key: childKey) + : Container(key: childKey, width: 100, height: 100, color: const Color(0xffff0000)), + ), + ), + ), + ), + ); + } + + await buildTest(VisualDensity.standard); + final RenderBox box = tester.renderObject(find.byKey(key)); + Rect childRect = tester.getRect(find.byKey(childKey)); + await tester.pumpAndSettle(); + expect(box.size, equals(const Size(132, 100))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(156, 124))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0)); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(132, 100))); + expect(childRect, equals(const Rect.fromLTRB(350, 250, 450, 350))); + + await buildTest(VisualDensity.standard, useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(88, 48))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: 3.0, vertical: 3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(112, 60))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + + await buildTest(const VisualDensity(horizontal: -3.0, vertical: -3.0), useText: true); + await tester.pumpAndSettle(); + childRect = tester.getRect(find.byKey(childKey)); + expect(box.size, equals(const Size(88, 36))); + expect(childRect, equals(const Rect.fromLTRB(372.0, 293.0, 428.0, 307.0))); + }); + + testWidgets('FilledButton.icon responds to applied padding', (WidgetTester tester) async { + const Key buttonKey = Key('test'); + const Key labelKey = Key('label'); + await tester.pumpWidget( + // When textDirection is set to TextDirection.ltr, the label appears on the + // right side of the icon. This is important in determining whether the + // horizontal padding is applied correctly later on + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FilledButton.icon( + key: buttonKey, + style: const ButtonStyle( + padding: MaterialStatePropertyAll(EdgeInsets.fromLTRB(16, 5, 10, 12)), + ), + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text( + 'Hello', + key: labelKey, + ), + ), + ), + ), + ); + + final Rect paddingRect = tester.getRect(find.byType(Padding)); + final Rect labelRect = tester.getRect(find.byKey(labelKey)); + final Rect iconRect = tester.getRect(find.byType(Icon)); + + // The right padding should be applied on the right of the label, whereas the + // left padding should be applied on the left side of the icon. + expect(paddingRect.right, labelRect.right + 10); + expect(paddingRect.left, iconRect.left - 16); + // Use the taller widget to check the top and bottom padding. + final Rect tallerWidget = iconRect.height > labelRect.height ? iconRect : labelRect; + expect(paddingRect.top, tallerWidget.top - 5); + expect(paddingRect.bottom, tallerWidget.bottom + 12); + }); + + group('Default FilledButton padding for textScaleFactor, textDirection', () { + const ValueKey buttonKey = ValueKey('button'); + const ValueKey labelKey = ValueKey('label'); + const ValueKey iconKey = ValueKey('icon'); + + const List textScaleFactorOptions = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0, 4.0]; + const List textDirectionOptions = [TextDirection.ltr, TextDirection.rtl]; + const List iconOptions = [null, Icon(Icons.add, size: 18, key: iconKey)]; + + // Expected values for each textScaleFactor. + final Map paddingWithoutIconStart = { + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final Map paddingWithoutIconEnd = { + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final Map paddingWithIconStart = { + 0.5: 12, + 1: 12, + 1.25: 11, + 1.5: 10, + 2: 8, + 2.5: 8, + 3: 8, + 4: 8, + }; + final Map paddingWithIconEnd = { + 0.5: 16, + 1: 16, + 1.25: 14, + 1.5: 12, + 2: 8, + 2.5: 6, + 3: 4, + 4: 4, + }; + final Map paddingWithIconGap = { + 0.5: 8, + 1: 8, + 1.25: 7, + 1.5: 6, + 2: 4, + 2.5: 4, + 3: 4, + 4: 4, + }; + + Rect globalBounds(RenderBox renderBox) { + final Offset topLeft = renderBox.localToGlobal(Offset.zero); + return topLeft & renderBox.size; + } + + /// Computes the padding between two [Rect]s, one inside the other. + EdgeInsets paddingBetween({ required Rect parent, required Rect child }) { + assert (parent.intersect(child) == child); + return EdgeInsets.fromLTRB( + child.left - parent.left, + child.top - parent.top, + parent.right - child.right, + parent.bottom - child.bottom, + ); + } + + for (final double textScaleFactor in textScaleFactorOptions) { + for (final TextDirection textDirection in textDirectionOptions) { + for (final Widget? icon in iconOptions) { + final String testName = [ + 'FilledButton, text scale $textScaleFactor', + if (icon != null) + 'with icon', + if (textDirection == TextDirection.rtl) + 'RTL', + ].join(', '); + testWidgets(testName, (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: const ColorScheme.light(), + // Force Material 2 defaults for the typography and size + // default values as the test was designed against these settings. + textTheme: Typography.englishLike2014, + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom(minimumSize: const Size(64, 36)), + ), + ), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: textScaleFactor, + ), + child: Directionality( + textDirection: textDirection, + child: Scaffold( + body: Center( + child: icon == null + ? FilledButton( + key: buttonKey, + onPressed: () {}, + child: const Text('button', key: labelKey), + ) + : FilledButton.icon( + key: buttonKey, + onPressed: () {}, + icon: icon, + label: const Text('button', key: labelKey), + ), + ), + ), + ), + ); + }, + ), + ), + ); + + final Element paddingElement = tester.element( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byType(Padding), + ), + ); + expect(Directionality.of(paddingElement), textDirection); + final Padding paddingWidget = paddingElement.widget as Padding; + + // Compute expected padding, and check. + + final double expectedStart = icon != null + ? paddingWithIconStart[textScaleFactor]! + : paddingWithoutIconStart[textScaleFactor]!; + final double expectedEnd = icon != null + ? paddingWithIconEnd[textScaleFactor]! + : paddingWithoutIconEnd[textScaleFactor]!; + final EdgeInsets expectedPadding = EdgeInsetsDirectional.fromSTEB(expectedStart, 0, expectedEnd, 0) + .resolve(textDirection); + + expect(paddingWidget.padding.resolve(textDirection), expectedPadding); + + // Measure padding in terms of the difference between the button and its label child + // and check that. + + final RenderBox labelRenderBox = tester.renderObject(find.byKey(labelKey)); + final Rect labelBounds = globalBounds(labelRenderBox); + final RenderBox? iconRenderBox = icon == null ? null : tester.renderObject(find.byKey(iconKey)); + final Rect? iconBounds = icon == null ? null : globalBounds(iconRenderBox!); + final Rect childBounds = icon == null ? labelBounds : labelBounds.expandToInclude(iconBounds!); + + // We measure the `InkResponse` descendant of the button + // element, because the button has a larger `RenderBox` + // which accommodates the minimum tap target with a height + // of 48. + final RenderBox buttonRenderBox = tester.renderObject( + find.descendant( + of: find.byKey(buttonKey), + matching: find.byWidgetPredicate( + (Widget widget) => widget is InkResponse, + ), + ), + ); + final Rect buttonBounds = globalBounds(buttonRenderBox); + final EdgeInsets visuallyMeasuredPadding = paddingBetween( + parent: buttonBounds, + child: childBounds, + ); + + // Since there is a requirement of a minimum width of 64 + // and a minimum height of 36 on material buttons, the visual + // padding of smaller buttons may not match their settings. + // Therefore, we only test buttons that are large enough. + if (buttonBounds.width > 64) { + expect( + visuallyMeasuredPadding.left, + expectedPadding.left, + ); + expect( + visuallyMeasuredPadding.right, + expectedPadding.right, + ); + } + + if (buttonBounds.height > 36) { + expect( + visuallyMeasuredPadding.top, + expectedPadding.top, + ); + expect( + visuallyMeasuredPadding.bottom, + expectedPadding.bottom, + ); + } + + // Check the gap between the icon and the label + if (icon != null) { + final double gapWidth = textDirection == TextDirection.ltr + ? labelBounds.left - iconBounds!.right + : iconBounds!.left - labelBounds.right; + expect(gapWidth, paddingWithIconGap[textScaleFactor]); + } + + // Check the text's height - should be consistent with the textScaleFactor. + final RenderBox textRenderObject = tester.renderObject( + find.descendant( + of: find.byKey(labelKey), + matching: find.byElementPredicate( + (Element element) => element.widget is RichText, + ), + ), + ); + final double textHeight = textRenderObject.paintBounds.size.height; + final double expectedTextHeight = 14 * textScaleFactor; + expect(textHeight, moreOrLessEquals(expectedTextHeight, epsilon: 0.5)); + }); + } + } + } + }); + + testWidgets('Override FilledButton default padding', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()), + home: Builder( + builder: (BuildContext context) { + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaleFactor: 2, + ), + child: Scaffold( + body: Center( + child: FilledButton( + style: FilledButton.styleFrom(padding: const EdgeInsets.all(22)), + onPressed: () {}, + child: const Text('FilledButton'), + ), + ), + ), + ); + }, + ), + ), + ); + + final Padding paddingWidget = tester.widget( + find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Padding), + ), + ); + expect(paddingWidget.padding, const EdgeInsets.all(22)); + }); + + testWidgets('By default, FilledButton shape outline is defined by shape.side', (WidgetTester tester) async { + const Color borderColor = Color(0xff4caf50); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(colorScheme: const ColorScheme.light(), textTheme: Typography.englishLike2014), + home: Center( + child: FilledButton( + style: FilledButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + side: BorderSide(width: 10, color: borderColor), + ), + minimumSize: const Size(64, 36), + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + expect(find.byType(FilledButton), paints ..drrect( + // Outer and inner rect that give the outline a width of 10. + outer: RRect.fromLTRBR(0.0, 0.0, 116.0, 36.0, const Radius.circular(16)), + inner: RRect.fromLTRBR(10.0, 10.0, 106.0, 26.0, const Radius.circular(16 - 10)), + color: borderColor) + ); + }); + + testWidgets('Fixed size FilledButtons', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + style: FilledButton.styleFrom(fixedSize: const Size(100, 100)), + onPressed: () {}, + child: const Text('100x100'), + ), + FilledButton( + style: FilledButton.styleFrom(fixedSize: const Size.fromWidth(200)), + onPressed: () {}, + child: const Text('200xh'), + ), + FilledButton( + style: FilledButton.styleFrom(fixedSize: const Size.fromHeight(200)), + onPressed: () {}, + child: const Text('wx200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(FilledButton, '100x100')), const Size(100, 100)); + expect(tester.getSize(find.widgetWithText(FilledButton, '200xh')).width, 200); + expect(tester.getSize(find.widgetWithText(FilledButton, 'wx200')).height, 200); + }); + + testWidgets('FilledButton with NoSplash splashFactory paints nothing', (WidgetTester tester) async { + Widget buildFrame({ InteractiveInkFeatureFactory? splashFactory }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: FilledButton( + style: FilledButton.styleFrom( + splashFactory: splashFactory, + ), + onPressed: () { }, + child: const Text('test'), + ), + ), + ), + ); + } + + // NoSplash.splashFactory, no splash circles drawn + await tester.pumpWidget(buildFrame(splashFactory: NoSplash.splashFactory)); + { + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('test'))); + final MaterialInkController material = Material.of(tester.element(find.text('test')))!; + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + } + + // InkRipple.splashFactory, one splash circle drawn. + await tester.pumpWidget(buildFrame(splashFactory: InkRipple.splashFactory)); + { + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.text('test'))); + final MaterialInkController material = Material.of(tester.element(find.text('test')))!; + await tester.pump(const Duration(milliseconds: 200)); + expect(material, paintsExactlyCountTimes(#drawCircle, 1)); + await gesture.up(); + await tester.pumpAndSettle(); + } + }); + + testWidgets('FilledButton uses InkSparkle only for Android non-web when useMaterial3 is true', (WidgetTester tester) async { + final ThemeData theme = ThemeData(useMaterial3: true); + + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Center( + child: FilledButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ); + + final InkWell buttonInkWell = tester.widget(find.descendant( + of: find.byType(FilledButton), + matching: find.byType(InkWell), + )); + + if (debugDefaultTargetPlatformOverride! == TargetPlatform.android && !kIsWeb) { + expect(buttonInkWell.splashFactory, equals(InkSparkle.splashFactory)); + } else { + expect(buttonInkWell.splashFactory, equals(InkRipple.splashFactory)); + } + }, variant: TargetPlatformVariant.all()); + + testWidgets('FilledButton.icon does not overflow', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/77815 + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + child: FilledButton.icon( + onPressed: () {}, + icon: const Icon(Icons.add), + label: const Text( // Much wider than 200 + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut a euismod nibh. Morbi laoreet purus.', + ), + ), + ), + ), + ), + ); + expect(tester.takeException(), null); + }); + + testWidgets('FilledButton.icon icon,label layout', (WidgetTester tester) async { + final Key buttonKey = UniqueKey(); + final Key iconKey = UniqueKey(); + final Key labelKey = UniqueKey(); + final ButtonStyle style = FilledButton.styleFrom( + padding: EdgeInsets.zero, + visualDensity: VisualDensity.standard, // dx=0, dy=0 + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SizedBox( + width: 200, + child: FilledButton.icon( + key: buttonKey, + style: style, + onPressed: () {}, + icon: SizedBox(key: iconKey, width: 50, height: 100), + label: SizedBox(key: labelKey, width: 50, height: 100), + ), + ), + ), + ), + ); + + // The button's label and icon are separated by a gap of 8: + // 46 [icon 50] 8 [label 50] 46 + // The overall button width is 200. So: + // icon.x = 46 + // label.x = 46 + 50 + 8 = 104 + + expect(tester.getRect(find.byKey(buttonKey)), const Rect.fromLTRB(0.0, 0.0, 200.0, 100.0)); + expect(tester.getRect(find.byKey(iconKey)), const Rect.fromLTRB(46.0, 0.0, 96.0, 100.0)); + expect(tester.getRect(find.byKey(labelKey)), const Rect.fromLTRB(104.0, 0.0, 154.0, 100.0)); + }); + + testWidgets('FilledButton maximumSize', (WidgetTester tester) async { + final Key key0 = UniqueKey(); + final Key key1 = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(textTheme: Typography.englishLike2014), + home: Scaffold( + body: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + key: key0, + style: TextButton.styleFrom( + minimumSize: const Size(24, 36), + maximumSize: const Size.fromWidth(64), + ), + onPressed: () { }, + child: const Text('A B C D E F G H I J K L M N O P'), + ), + FilledButton.icon( + key: key1, + style: TextButton.styleFrom( + minimumSize: const Size(24, 36), + maximumSize: const Size.fromWidth(104), + ), + onPressed: () {}, + icon: Container(color: Colors.red, width: 32, height: 32), + label: const Text('A B C D E F G H I J K L M N O P'), + ), + ], + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(key0)), const Size(64.0, 224.0)); + expect(tester.getSize(find.byKey(key1)), const Size(104.0, 224.0)); + }); + + testWidgets('Fixed size FilledButton, same as minimumSize == maximumSize', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FilledButton( + style: FilledButton.styleFrom(fixedSize: const Size(200, 200)), + onPressed: () { }, + child: const Text('200x200'), + ), + FilledButton( + style: FilledButton.styleFrom( + minimumSize: const Size(200, 200), + maximumSize: const Size(200, 200), + ), + onPressed: () { }, + child: const Text('200,200'), + ), + ], + ), + ), + ), + ); + + expect(tester.getSize(find.widgetWithText(FilledButton, '200x200')), const Size(200, 200)); + expect(tester.getSize(find.widgetWithText(FilledButton, '200,200')), const Size(200, 200)); + }); + + testWidgets('FilledButton changes mouse cursor when hovered', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: FilledButton( + style: FilledButton.styleFrom( + enabledMouseCursor: SystemMouseCursors.text, + disabledMouseCursor: SystemMouseCursors.grab, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: Offset.zero); + + await tester.pump(); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); + + // Test cursor when disabled + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: FilledButton( + style: FilledButton.styleFrom( + enabledMouseCursor: SystemMouseCursors.text, + disabledMouseCursor: SystemMouseCursors.grab, + ), + onPressed: null, + child: const Text('button'), + ), + ), + ), + ); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.grab); + + // Test default cursor + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: FilledButton( + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.click); + + // Test default cursor when disabled + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MouseRegion( + cursor: SystemMouseCursors.forbidden, + child: FilledButton( + onPressed: null, + child: Text('button'), + ), + ), + ), + ); + + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); + }); + + testWidgets('Ink Response shape matches Material shape', (WidgetTester tester) async { + Widget buildFrame({BorderSide? side}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: FilledButton( + style: FilledButton.styleFrom( + side: side, + shape: const RoundedRectangleBorder( + side: BorderSide( + color: Color(0xff0000ff), + width: 0, + ), + ), + ), + onPressed: () { }, + child: const Text('FilledButton'), + ), + ), + ), + ); + } + + const BorderSide borderSide = BorderSide(width: 10, color: Color(0xff00ff00)); + await tester.pumpWidget(buildFrame(side: borderSide)); + expect( + tester.widget(find.byType(InkWell)).customBorder, + const RoundedRectangleBorder(side: borderSide), + ); + + await tester.pumpWidget(buildFrame()); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(InkWell)).customBorder, + const RoundedRectangleBorder( + side: BorderSide( + color: Color(0xff0000ff), + width: 0.0, + ), + ), + ); + }); + + testWidgets('FilledButton.styleFrom can be used to set foreground and background colors', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: FilledButton( + style: FilledButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.purple, + ), + onPressed: () {}, + child: const Text('button'), + ), + ), + ), + ); + + final Material material = tester.widget(find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Material), + )); + expect(material.color, Colors.purple); + expect(material.textStyle!.color, Colors.white); + }); + + testWidgets('FilledButton statesController', (WidgetTester tester) async { + int count = 0; + void valueChanged() { + count += 1; + } + final MaterialStatesController controller = MaterialStatesController(); + controller.addListener(valueChanged); + + await tester.pumpWidget( + MaterialApp( + home: Center( + child: FilledButton( + statesController: controller, + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ); + + expect(controller.value, {}); + expect(count, 0); + + final Offset center = tester.getCenter(find.byType(FilledButton)); + final TestGesture gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(); + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, {MaterialState.hovered}); + expect(count, 1); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, {}); + expect(count, 2); + + await gesture.moveTo(center); + await tester.pumpAndSettle(); + + expect(controller.value, {MaterialState.hovered}); + expect(count, 3); + + await gesture.down(center); + await tester.pumpAndSettle(); + + expect(controller.value, {MaterialState.hovered, MaterialState.pressed}); + expect(count, 4); + + await gesture.up(); + await tester.pumpAndSettle(); + + expect(controller.value, {MaterialState.hovered}); + expect(count, 5); + + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + + expect(controller.value, {}); + expect(count, 6); + + await gesture.down(center); + await tester.pumpAndSettle(); + expect(controller.value, {MaterialState.hovered, MaterialState.pressed}); + expect(count, 8); // adds hovered and pressed - two changes + + // If the button is rebuilt disabled, then the pressed state is + // removed. + await tester.pumpWidget( + MaterialApp( + home: Center( + child: FilledButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(controller.value, {MaterialState.hovered, MaterialState.disabled}); + expect(count, 10); // removes pressed and adds disabled - two changes + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + expect(controller.value, {MaterialState.disabled}); + expect(count, 11); + await gesture.removePointer(); + }); + + testWidgets('Disabled FilledButton statesController', (WidgetTester tester) async { + int count = 0; + void valueChanged() { + count += 1; + } + final MaterialStatesController controller = MaterialStatesController(); + controller.addListener(valueChanged); + await tester.pumpWidget( + MaterialApp( + home: Center( + child: FilledButton( + statesController: controller, + onPressed: null, + child: const Text('button'), + ), + ), + ), + ); + expect(controller.value, {MaterialState.disabled}); + expect(count, 1); + }); + +} + +TextStyle _iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget( + find.descendant(of: find.byIcon(icon), matching: find.byType(RichText)), + ); + return iconRichText.text.style!; +} diff --git a/packages/flutter/test/material/filled_button_theme_test.dart b/packages/flutter/test/material/filled_button_theme_test.dart new file mode 100644 index 000000000000..f793ac43d9cd --- /dev/null +++ b/packages/flutter/test/material/filled_button_theme_test.dart @@ -0,0 +1,253 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Passing no FilledButtonTheme returns defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme), + home: Scaffold( + body: Center( + child: FilledButton( + onPressed: () { }, + child: const Text('button'), + ), + ), + ), + ), + ); + + final Finder buttonMaterial = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Material), + ); + + final Material material = tester.widget(buttonMaterial); + expect(material.animationDuration, const Duration(milliseconds: 200)); + expect(material.borderRadius, null); + expect(material.color, colorScheme.primary); + expect(material.elevation, 0); + expect(material.shadowColor, const Color(0xff000000)); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle!.color, colorScheme.onPrimary); + expect(material.textStyle!.fontFamily, 'Roboto'); + expect(material.textStyle!.fontSize, 14); + expect(material.textStyle!.fontWeight, FontWeight.w500); + + final Align align = tester.firstWidget(find.ancestor(of: find.text('button'), matching: find.byType(Align))); + expect(align.alignment, Alignment.center); + }); + + group('[Theme, TextTheme, FilledButton style overrides]', () { + const Color foregroundColor = Color(0xff000001); + const Color backgroundColor = Color(0xff000002); + const Color disabledForegroundColor = Color(0xff000003); + const Color disabledBackgroundColor = Color(0xff000004); + const Color shadowColor = Color(0xff000005); + const double elevation = 1; + const TextStyle textStyle = TextStyle(fontSize: 12.0); + const EdgeInsets padding = EdgeInsets.all(3); + const Size minimumSize = Size(200, 200); + const BorderSide side = BorderSide(color: Colors.green, width: 2); + const OutlinedBorder shape = RoundedRectangleBorder(side: side, borderRadius: BorderRadius.all(Radius.circular(2))); + const MouseCursor enabledMouseCursor = SystemMouseCursors.text; + const MouseCursor disabledMouseCursor = SystemMouseCursors.grab; + const MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.shrinkWrap; + const Duration animationDuration = Duration(milliseconds: 25); + const bool enableFeedback = false; + const AlignmentGeometry alignment = Alignment.centerLeft; + + final ButtonStyle style = FilledButton.styleFrom( + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + disabledForegroundColor: disabledForegroundColor, + disabledBackgroundColor: disabledBackgroundColor, + shadowColor: shadowColor, + elevation: elevation, + textStyle: textStyle, + padding: padding, + minimumSize: minimumSize, + side: side, + shape: shape, + enabledMouseCursor: enabledMouseCursor, + disabledMouseCursor: disabledMouseCursor, + tapTargetSize: tapTargetSize, + animationDuration: animationDuration, + enableFeedback: enableFeedback, + alignment: alignment, + ); + + Widget buildFrame({ ButtonStyle? buttonStyle, ButtonStyle? themeStyle, ButtonStyle? overallStyle }) { + final Widget child = Builder( + builder: (BuildContext context) { + return FilledButton( + style: buttonStyle, + onPressed: () { }, + child: const Text('button'), + ); + }, + ); + return MaterialApp( + theme: ThemeData.from(colorScheme: const ColorScheme.light()).copyWith( + filledButtonTheme: FilledButtonThemeData(style: overallStyle), + ), + home: Scaffold( + body: Center( + // If the FilledButtonTheme widget is present, it's used + // instead of the Theme's ThemeData.FilledButtonTheme. + child: themeStyle == null ? child : FilledButtonTheme( + data: FilledButtonThemeData(style: themeStyle), + child: child, + ), + ), + ), + ); + } + + final Finder findMaterial = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Material), + ); + + final Finder findInkWell = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(InkWell), + ); + + const Set enabled = {}; + const Set disabled = { MaterialState.disabled }; + const Set hovered = { MaterialState.hovered }; + const Set focused = { MaterialState.focused }; + const Set pressed = { MaterialState.pressed }; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget(findMaterial); + final InkWell inkWell = tester.widget(findInkWell); + expect(material.textStyle!.color, foregroundColor); + expect(material.textStyle!.fontSize, 12); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor!, enabled), enabledMouseCursor); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor!, disabled), disabledMouseCursor); + expect(inkWell.overlayColor!.resolve(hovered), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(focused), foregroundColor.withOpacity(0.12)); + expect(inkWell.overlayColor!.resolve(pressed), foregroundColor.withOpacity(0.12)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(FilledButton)), const Size(200, 200)); + final Align align = tester.firstWidget(find.ancestor(of: find.text('button'), matching: find.byType(Align))); + expect(align.alignment, alignment); + } + + testWidgets('Button style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(themeStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(overallStyle: style)); + await tester.pumpAndSettle(); + checkButton(tester); + }); + + // Same as the previous tests with empty ButtonStyle's instead of null. + + testWidgets('Button style overrides defaults, empty theme and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: style, themeStyle: const ButtonStyle(), overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Button theme style overrides defaults, empty button and overall styles', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), themeStyle: style, overallStyle: const ButtonStyle())); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + + testWidgets('Overall Theme button theme style overrides defaults, null theme and empty overall style', (WidgetTester tester) async { + await tester.pumpWidget(buildFrame(buttonStyle: const ButtonStyle(), overallStyle: style)); + await tester.pumpAndSettle(); // allow the animations to finish + checkButton(tester); + }); + }); + + testWidgets('Theme shadowColor', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + const Color shadowColor = Color(0xff000001); + const Color overriddenColor = Color(0xff000002); + + Widget buildFrame({ Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor }) { + return MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme).copyWith( + shadowColor: overallShadowColor, + ), + home: Scaffold( + body: Center( + child: FilledButtonTheme( + data: FilledButtonThemeData( + style: FilledButton.styleFrom( + shadowColor: themeShadowColor, + ), + ), + child: Builder( + builder: (BuildContext context) { + return FilledButton( + style: FilledButton.styleFrom( + shadowColor: shadowColor, + ), + onPressed: () { }, + child: const Text('button'), + ); + }, + ), + ), + ), + ), + ); + } + + final Finder buttonMaterialFinder = find.descendant( + of: find.byType(FilledButton), + matching: find.byType(Material), + ); + + await tester.pumpWidget(buildFrame()); + Material material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, Colors.black); //default + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); +} diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index 32f90f11700d..c37c8a38dca3 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -695,6 +695,7 @@ void main() { drawerTheme: const DrawerThemeData(), elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(backgroundColor: Colors.green)), expansionTileTheme: const ExpansionTileThemeData(backgroundColor: Colors.black), + filledButtonTheme: FilledButtonThemeData(style: FilledButton.styleFrom(foregroundColor: Colors.green)), floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.black), iconButtonTheme: IconButtonThemeData(style: IconButton.styleFrom(foregroundColor: Colors.pink)), listTileTheme: const ListTileThemeData(), @@ -808,6 +809,7 @@ void main() { drawerTheme: const DrawerThemeData(), elevatedButtonTheme: const ElevatedButtonThemeData(), expansionTileTheme: const ExpansionTileThemeData(backgroundColor: Colors.black), + filledButtonTheme: const FilledButtonThemeData(), floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.white), iconButtonTheme: const IconButtonThemeData(), listTileTheme: const ListTileThemeData(), @@ -907,6 +909,7 @@ void main() { drawerTheme: otherTheme.drawerTheme, elevatedButtonTheme: otherTheme.elevatedButtonTheme, expansionTileTheme: otherTheme.expansionTileTheme, + filledButtonTheme: otherTheme.filledButtonTheme, floatingActionButtonTheme: otherTheme.floatingActionButtonTheme, iconButtonTheme: otherTheme.iconButtonTheme, listTileTheme: otherTheme.listTileTheme, @@ -1005,6 +1008,7 @@ void main() { expect(themeDataCopy.drawerTheme, equals(otherTheme.drawerTheme)); expect(themeDataCopy.elevatedButtonTheme, equals(otherTheme.elevatedButtonTheme)); expect(themeDataCopy.expansionTileTheme, equals(otherTheme.expansionTileTheme)); + expect(themeDataCopy.filledButtonTheme, equals(otherTheme.filledButtonTheme)); expect(themeDataCopy.floatingActionButtonTheme, equals(otherTheme.floatingActionButtonTheme)); expect(themeDataCopy.iconButtonTheme, equals(otherTheme.iconButtonTheme)); expect(themeDataCopy.listTileTheme, equals(otherTheme.listTileTheme)); @@ -1140,6 +1144,7 @@ void main() { 'dividerTheme', 'drawerTheme', 'elevatedButtonTheme', + 'filledButtonTheme', 'floatingActionButtonTheme', 'iconButtonTheme', 'listTileTheme',